Skip to content

API route conventions — Kindgi platform

View as Markdown

Source in the repository.

Status: normative — every /v1/* route follows these conventions. Related: ADDING-A-ROUTE.md (authoring checklist), packages/api/openapi.json (generated from the route registry; exported as @kindgi/api/openapi.json).

This document fixes the on-wire conventions — URL shapes, authentication, request and response envelopes, pagination, streaming, signed exports, and error propagation — so the API, the generated OpenAPI document, and client SDKs agree. The OpenAPI document is their executable form; drift tests keep routes, the OpenAPI registry, and the TypeScript client in sync.


  • Base path: /v1/. All routes are versioned. Breaking changes ship at /v2/ — never mutate /v1/ shapes.
  • No trailing slash on canonical URLs (e.g. /v1/runs, not /v1/runs/). Requests with trailing slash 301-redirect to the canonical form.
  • Version bump policy: additive changes stay in /v1/. Renames, removed fields, or changed semantics require /v2/.

Resources are plural, kebab-case, singular resource ID at the tail:

  • Collection: POST /v1/runs, GET /v1/runs, GET /v1/runs/:runId, DELETE /v1/runs/:runId.
  • Sub-collection: GET /v1/runs/:runId/journal, POST /v1/runs/:runId/cancel.
  • Cross-cutting stream: GET /v1/runs/:runId/stream (SSE).

Verb suffixes are used sparingly for non-CRUD actions where a resource-shaped route would be misleading (e.g. cancel, resume, dry-run). Prefer plain CRUD when possible.

Catalog resources (agents, flows, tools, guardrails, policies, schedules, triggers, webhook endpoints, …) are not hard-deleted. They are removed with POST /v1/<resource>/:id/unregister:

  • the row is kept, marked unregistered, and hidden from every read (a later GET answers 404);
  • the response is { <idField>: string, unregistered: boolean }, where unregistered is false when the resource was unknown or already unregistered, so the call is idempotent;
  • the runtime removes unregistered rows later, under its retention policy.
  • :runId, :agentId, :flowId, :approvalId, :conversationId. Camel-case with Id suffix. Matches TypeScript type names one-to-one.
  • Always the branded id type on the wire (opaque strings; UUIDs today but the SDK / server never assume format).

Every non-public request carries:

Authorization: Bearer <api-token>

API keys are minted server-side (POST /v1/tokens, tenant admins only) and return a plain string, once. A key is a service account in its tenant (service_account:<tokenId>) and carries:

  • Its tenant.
  • A role: admin (administers the tenant, manages keys) or member.
  • Explicit capabilities (env:write, secrets:write, …): a caller can only grant capabilities it holds.
  • An optional project scope and expiration.

GET /v1/tokens lists keys and GET /v1/tokens/{tokenId} reads one; neither returns a secret. POST /v1/tokens/{tokenId}/revoke takes effect on the next request.

The SDK's AuthConfig (sdks/typescript/src/auth.ts) already emits this header via its apiToken mode. No SDK change needed.

The only fully-public routes are:

  • GET /health — liveness. No auth. Returns { ok: true }.
  • GET /v1/openapi.json — the OpenAPI document.
  • Inbound webhook receivers, when a deployment turns them on — authenticated by an HMAC signature header, not a Bearer token. There's no receiver route yet: /v1/webhooks only manages the webhook triggers.

The server derives tenantId from the token; callers never pass it explicitly on requests. Every DB read/write inside a route handler flows through withTenantConnection(client, tenantId, ...).

  • Missing/malformed Authorization → 401 unauthorized with error code: 'auth-missing'.
  • Expired or revoked token → 401 unauthorized with error code: 'auth-expired' or 'auth-revoked'.
  • Valid token, resource is out of scope for its tenant → 404 not-found, never 403 forbidden (avoid leaking resource existence across tenants).

  • All bodies are JSON. Content-Type: application/json; charset=utf-8 is required on POST/PUT/PATCH.
  • Empty body accepted where semantically appropriate (POST /v1/runs/:runId/cancel takes no body).
  • Field names are camelCase, matching the SDK's TypeScript types verbatim. No wire-format renames.
  • Unknown top-level fields → 400 bad-request with error code: 'unknown-field'. Strict validation on the request; permissive on the response (for forward compatibility).

Any mutating request may include:

Idempotency-Key: <caller-supplied-uuid>

Semantics:

  • Server stores (tenantId, route, key) → response for 24h, for a response that took effect (a status below 400). A refusal (4xx) or a failure (5xx) isn't stored, so a retry after fixing the cause runs again.
  • Retries with the same key return the stored response byte-identical (including status code), marked X-Idempotent-Replay: true.
  • Retries with the same key but a different body → 409 conflict with error code: 'idempotency-key-body-mismatch'.
  • A retry sent while the first request still runs → 409 conflict with error code: 'idempotency-key-in-flight' and Retry-After, when the store holds keys (IdempotencyStore.holds; the runtime's does from 0.1.5). It doesn't run the operation again; retry after it, and you get the first request's answer. The hold lasts 30 s and is renewed while the request runs, so a crashed request's key frees within 30 s.
  • Applies to POST, PUT, PATCH, DELETE.

@kindgi/client sends the Idempotency-Key header on mutating calls when the caller passes idempotencyKey.


2xx status codes carry the resource / operation result as the JSON body directly. No wrapper envelope for success.

{
"id": "run-abc123",
"status": "running",
"flowId": "agent.turn",
"flowVersion": "1.0.0",
"startedAt": "2026-09-18T12:34:56.789Z"
}

4xx and 5xx responses carry a discriminated error envelope:

{
"error": {
"code": "unresolved-tool",
"message": "Tool \"acme.citation-lookup\" is not registered for agent \"acme.drafting\"",
"details": {
"toolId": "acme.citation-lookup"
},
"requestId": "req-01H8XYZ..."
}
}
  • code is a stable machine-readable discriminant. Values match existing domain error codes (unresolved-tool, guardrail-violation, hitl-required, agent-turn-aborted, etc.). New codes only add.
  • message is human-readable, not part of the API contract.
  • details is optional, kind-specific.
  • requestId is server-assigned; always present.

The SDK's fromWire(json) (sdks/typescript/src/errors.ts) matches on code.

Domain error code HTTP status
not-found, run-not-found, agent-not-found 404
already-terminal, run-already-terminal 409
idempotency-key-body-mismatch 409
idempotency-key-in-flight 409
duplicate-* 409
slug-conflict, project-default-already-exists 409
validation-failed, unknown-field, bad-input 400
unresolved-tool, unresolved-guardrail 400
hitl-required 409
guardrail-violation 422
agent-turn-aborted, budget-exceeded 422
flow-unbound, flow-runs-not-supported, flow-resume-not-supported 422
auth-missing, auth-expired, auth-revoked 401
permission-denied 403
rate-limit-exceeded 429
database-busy (a GET or HEAD only) 503
Anything else server-caused 500

A 429 rate-limit-exceeded carries Retry-After, in whole seconds, when the limit is over a time window, as schema-compile-time, template-render-time and the request rate limits are; a limit that counts what's waiting, not time, carries none. When a tenant's limit refused the request, details.limit names it. schema-compile-time is the time the tenant's schemas spent compiling in the last 60 seconds, against details.budgetMs. It counts every compile in a request that publishes schemas, and a schema compiled again in any other request. A publish it refuses stores nothing. template-render-time is the time the tenant's instructions templates spent rendering in the last 60 seconds, against details.budgetMs. An agent turn whose work either budget refuses fails with rate-limit-exceeded and the same details: POST /v1/runs answers it as this 429, with Retry-After and details.runId, and the run reads as failed with that code.

A 503 database-busy answers a GET or HEAD that the database was too busy to answer in time: a lock or statement timeout ended its query, or no pooled connection came free within the wait. It carries Retry-After (5 seconds). A request with any other method that the database was too busy for answers a 500 (internal-server-error, or the code of the binding error that stopped it, such as persistence-error): a write can have saved part of its work before the timeout, and clients retry a 503 as an answer that nothing ran.

The storage failure is marked code: 'database-busy', on the error or down its cause chain. A thrown error reaches mapThrownError, which checks for the mark. A route that answers a binding's Result error at its code's status asks databaseBusyAnswer(error, c) (middleware/error-mapper.ts) first and returns its answer when it gives one. A binding that re-codes a storage failure under its own code keeps the failure as the error's cause, so the read answers the same whether the binding threw the failure or returned it.


Every list endpoint accepts:

  • ?limit=<int> — 1..100, default 25.
  • ?cursor=<opaque-string> — server-issued from a prior response.

Response body:

{
"data": [ … ],
"nextCursor": "cursor-abc123",
"hasMore": true
}
Excerpt
  • nextCursor is absent when hasMore: false.
  • Cursors are opaque — SDKs treat them as strings. Server encodes whatever it needs (page number, last-id, whatever).
  • A runtime with a cursor sealer (cursorSealer) seals every nextCursor at the edge (AES-256-GCM, k1.<kid>.<nonce>.<body>): it shows nothing of the row it points after, even one the list hid. A sealed cursor opens only for the same tenant, caller, list (the path) and filters (the query parameters other than cursor and limit, so the page size may change mid-scan), within a day; otherwise 400 bad-input, and the client starts again without it. A plain cursor still passes as it is.
  • A page may be empty while hasMore is true (every row of a window was one the caller can't read): continue with nextCursor.
  • Filters go as query params: ?status=running&agentId=....
  • Sort order is fixed per endpoint (documented per route); no ?sort= unless explicitly supported.

Matches the SDK's Filter<TStatus> + Cursor types verbatim.


GET /v1/runs/:runId/stream returns Server-Sent Events. Each event is one RunEvent (the union defined by @kindgi/specs/run-event.schema.json).

id: <eventId>
event: run.step-completed
data: {"eventId":"...","runId":"...","tenantId":"...","kind":"run.step-completed",...}
  • id line: monotonic per run, used as Last-Event-Id on reconnect.
  • event line: the RunEvent.kind value.
  • data line: the full serialized RunEvent JSON. Callers using EventSource-compatible parsers can ignore the event line and switch on data.kind.

The SDK's readSse<T>() sends Last-Event-Id: <lastSeenEventId> on reconnect. Server resumes from that id + 1 exclusive; missed events are replayed from the journal.

Server closes the SSE connection cleanly after emitting run.completed / run.failed / run.cancelled (whichever is the terminal event for that run). Callers observe end-of-stream normally.


For the runs resource specifically (the largest surface):

Verb + path Kernel op Notes
POST /v1/runs runGraph(...) Body: { agent | flow, input, options?: { dryRun?: boolean, wait?: boolean } }. Returns the run row with its output: 201 once the run completes, fails or suspends; 202 right away with wait: false.
GET /v1/runs/:runId Load row from kernel_runs Returns the run row, with output once completed and parentRunId / parentNodeId on a child run.
GET /v1/runs List with pagination Filters: parentRunId (a run's children), topLevel=true. include=output adds each run's output.
POST /v1/runs/:runId/cancel cancelRun(...) Empty body. Returns updated RunResult.
POST /v1/runs/:runId/resume completeToken(...), then the host's resumeRun(...) Body: { waitpointId, value? }. Ids starting with child: are reserved for the runtime.
GET /v1/runs/:runId/journal readJournal(...) Paginated: ?since=<sequence>&limit=<int>.
GET /v1/runs/:runId/stream SSE from journal + in-process bus See §6.

The other resources (agents, flows, tools, guardrails, approvals, etc.) follow the same shape — CRUD verbs map to package-level functions; non-CRUD actions map to specific ops.


Kernel / package-level Result<T, E> errors are translated at the route layer:

  • Success ok → serialize T as JSON body with 2xx status.
  • Failure err → look up the error code in the mapping table (§4.3), serialize as { error: {...} } with the mapped HTTP status.

Every err.code known to packages/* must have a status mapping. New error codes require adding a row to §4.3 (in this doc) + the route layer's mapping table.

Domain errors are the ONLY response body shape for 4xx/5xx. Framework crashes (thrown exceptions, uncaught DB errors) get caught by a top-level middleware that produces { error: { code: 'internal-server-error', message: '...', requestId: '...' } } with 500.


  • GraphQL / gRPC / other transports. REST + SSE only.
  • Batching endpoints. No POST /v1/runs/batch — clients send N requests.

Signed export routes (POST /v1/provenance/:runId/export, POST /v1/approvals/:approvalId/audit-bundle, any future signed export) MUST return the same wire envelope so SDK clients can share a single verification helper:

{
"<resourceId>": "...", // e.g. "runId" or "approvalId"
"bundle": "base64-of-canonical-json",
"bundleSchemaVersion": <version>, // semver string OR integer; kind-specific
"algorithm": "ed25519",
"signingKeyId": "...",
"signature": "base64-signature-bytes",
"publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n",
"canonicalization": "sorted-key-json",
"exportedAt": "2026-09-19T12:34:56.789Z"
}
Excerpt

Canonicalization algorithm (canonicalization: "sorted-key-json") is provided by canonicalize from @kindgi/schema: JSON with object keys sorted lexicographically at every level, no whitespace, UTF-8 bytes. Arrays preserve declaration order. This algorithm is stable across resources — provenance and audit bundles share it verbatim so verification code is identical.

Verifier flow (client-side, no server round trip):

  1. Base64-decode bundle → canonical bytes.
  2. Base64-decode signature → 64 signature bytes.
  3. parsePublicKeyPem(publicKey) from @kindgi/crypto.
  4. verifyEd25519(publicKey, bundleBytes, signatureBytes).
  5. If needed, JSON.parse(bundleBytes.toString('utf8')) for structured inspection — the canonical form is regular JSON.

Deployments without a signingKey binding mounted return 404 signing-not-configured on every signed-export route; unknown signingKeyId returns 404 signing-key-not-found. Both error codes are shared across resources.

Response envelope shape is guardrail across resources; the bundle body schema is per-resource (see ExportProvenanceResult / ExportAuditBundleResult in the OpenAPI document). New signed exports MUST reuse this envelope.