Skip to content

HTTP API

This page lists every HTTP call the gateway accepts, what you send, and what comes back. The gRPC and MCP sections at the end map the same calls onto those transports.

There is no OpenAPI document yet. The bodies on this page come from a run of the gateway, not from a schema.

Conventions

These apply to every call.

  • You name a record as <class>/<id>. In a URL that is two path segments. In a body it is one string.
  • Send Authorization: Bearer <token> on every request.
  • You can send X-Kslate-Scope: <opaque, ≤256 bytes>. The gateway uses it to work out inferred based_on.
  • Request bodies are JSON, at most 4 MiB. Only claim lets you omit the body.
  • Every error body has an error key with the error code. Most also have record and field. The STALE and HELD bodies carry field but not record, because their shapes are fixed by the design.
  • Timestamps are RFC 3339 with the server’s offset.

Status codes

CodeMeaning
200Read, or write applied
201Write applied and the record was created by it
400MALFORMED (bad JSON, missing required key, bad type, enum violation, lease out of range, …)
401UNAUTHENTICATED
403NO_ROLE, SCOPE_DENIED, WEAK_IDENTITY, IDENTITY_MISMATCH
404NOT_FOUND (record, or unknown class)
409STALE, STALE_PREMISE, HELD
410CLASS_GONE
422EVIDENCE_REQUIRED, UNKNOWN_FIELD, NOT_WRITABLE, NOT_APPEND_FIELD, NOT_TASK_FIELD, INVALID_CHECKPOINT
429RATE_LIMITED (this address exhausted its budget of rejected credentials; gRPC: ResourceExhausted; MCP: the same body at 429 on the transport, before any protocol method runs)
500INTERNAL
503UNAVAILABLE (Postgres unreachable, or the schema is being re-applied)

Rejection order

If a request fails more than one check, kslate returns the first failure in this order. The same request always gets the same answer.

  1. UNAUTHENTICATED
  2. NOT_FOUND, when the class does not exist
  3. NO_ROLE
  4. CLASS_GONE (writes only)
  5. MALFORMED, when the record id does not match the class idPattern
  6. UNKNOWN_FIELD
  7. SCOPE_DENIED
  8. WEAK_IDENTITY (put, append, claim, and a checkpoint save)
  9. NOT_WRITABLE / NOT_APPEND_FIELD / NOT_TASK_FIELD
  10. IDENTITY_MISMATCH
  11. HELD
  12. EVIDENCE_REQUIRED
  13. STALE
  14. STALE_PREMISE

Two more checks happen inside that order:

  • Other MALFORMED cases come back at the point where the gateway first needs the bad input. That is always after the authorization checks.
  • On a put, the type and enum checks run between IDENTITY_MISMATCH and EVIDENCE_REQUIRED.
  • The checkpoint gate (INVALID_CHECKPOINT) runs between STALE and STALE_PREMISE.

The checks from HELD onward run inside the write transaction, under the record’s row lock. Everything before them runs without touching the record.

The order goes authorization, then validation, then concurrency. Only the last group needs the row lock, so a request that was never going to be allowed is refused without one.

Two placements are worth knowing:

  • STALE comes before STALE_PREMISE. Either way you have to re-read and send a new expected_version, so the target field’s own staleness is the more useful answer.
  • WEAK_IDENTITY sits between SCOPE_DENIED and the mode checks. Scope asks whether this identity may touch the field. The floor asks whether what the caller proved about itself is good enough for the field to change. Both are about the same write at the same moment.

A read is never CLASS_GONE. A caller with no role in the last known class gets NO_ROLE at step 3 and learns nothing about the class.

Routes

GET  /v1/classes
GET  /v1/classes/{name}
GET  /v1/records/{class}?<field>=<value>&limit=N          query
GET  /v1/records/{class}/{id}?fields=a,b                  get
PUT  /v1/records/{class}/{id}/fields/{field}              put
POST /v1/records/{class}/{id}/fields/{field}/entries      append
PUT  /v1/records/{class}/{id}/fields/{field}/claim        claim (lease_seconds 0 releases)
PUT  /v1/records/{class}/{id}/checkpoint                  checkpoint save
GET  /v1/records/{class}/{id}/checkpoint?role=            checkpoint load
GET  /v1/records/{class}/{id}/history?field=&limit=&before=
GET  /readyz                                              also on the metrics port

get

Read a record. Use it before a write to learn the current version of each field.

GET /v1/records/{class}/{id}?fields=a,b. Leave out fields to get every field your role may read. Add entries_limit=N to cap how many entries each append field returns. The field’s version stays the full entry count.

HTTP 200
{
  "record": "incidents/inc-4471",
  "record_version": 11,
  "fields": {
    "planned_action": {
      "value": "fail over payments-db to replica 2",
      "version": 2,
      "author": "apikey:diagnoser",
      "identity_strength": "shared-secret",
      "evidence": "hypotheses@2",
      "updated_at": "2026-10-01T22:00:36.776597-07:00",
      "based_on": [ { "field": "hypotheses", "version": 2 } ]
    }
  }
}

Each field has value, version, author, identity_strength and updated_at. It also has evidence, based_on and invocation when the writer supplied them.

Details:

  • identity_strength is pod-bound, sa-bound, issuer-declared or shared-secret: how well the author’s credential was bound (Identity strength). A field last written before the gateway recorded it has none.
  • An append field returns version (the entry count) and entries[]. Each entry has seq, value, author, identity_strength, evidence, updated_at, based_on and invocation.
  • A task field returns owner and lease_expires_at while someone holds it.
  • Fields with no value yet are left out.
  • Asking for a field your role may not read returns SCOPE_DENIED.

put

Write one value to a versioned or soft field.

PUT /v1/records/{class}/{id}/fields/{field}

{ "value": <json>, "expected_version": 1, "evidence": "…", "based_on": [ { "field": "hypotheses", "version": 2 } ], "invocation": { … } }
KeyVersionedSoft
valuerequired, must match the field’s type and enumrequired, must match type
expected_versionrequired; 0 = must not existignored
evidencerequired if the class says sosame
based_onoptional; [] disables inferencesame
based_on[].kindoptional; fence marks a premise declared to prove the writer still holds a claim (based_on)same
invocationoptional; a JSON object of at most 1 KiB saying which call produced the write. Stored and returned unchanged, never checkedsame

A successful write returns the new version of the field.

HTTP 200 or 201
{ "record": "incidents/inc-4471", "field": "status", "version": 2, "record_version": 5 }

If the field has moved since you read it, you get STALE with the current value and who wrote it.

HTTP 409 STALE
{
  "error": "STALE",
  "field": "status",
  "your_version": 1,
  "current_version": 2,
  "current_value": "mitigating",
  "current_author": "apikey:detector",
  "current_evidence": null,
  "current_updated_at": "2026-10-01T22:00:36.628631-07:00",
  "diff": "--- current\n+++ proposed\n-\"mitigating\"\n+\"resolved\"\n"
}

If a field you named in based_on has moved, you get STALE_PREMISE. moved[] lists each based_on entry that changed.

HTTP 409 STALE_PREMISE
{
  "error": "STALE_PREMISE",
  "record": "incidents/inc-4471",
  "field": "actions_taken",
  "current_version": 7,
  "current_author": "apikey:remediator",
  "moved": [
    { "field": "planned_action", "your_version": 4, "current_version": 5,
      "current_author": "apikey:diagnoser", "current_evidence": "hypotheses@6" }
  ]
}

If the class requires a checkpoint before status can be set to resolved, and that checkpoint is missing, you get INVALID_CHECKPOINT.

HTTP 422 INVALID_CHECKPOINT
{ "error": "INVALID_CHECKPOINT", "field": "status",
  "message": "required role(s) have not checkpointed this record yet: remediator",
  "record": "incidents/inc-4471" }

Details:

  • If your role may write the field but not read it, a STALE body has current_value, current_author, current_evidence and current_updated_at null, and diff empty.
  • diff is replaced by a short note when either value is over 64 KiB or 500 lines.
  • In a STALE_PREMISE body, the top-level field, current_version and current_author always describe the field you were writing, whether or not it moved. If it does not exist yet, current_version is 0 and current_author is null.

append

Add one entry to an append field.

POST /v1/records/{class}/{id}/fields/{field}/entries

{ "entry": <json>, "evidence": "…", "based_on": [ … ], "invocation": { … } }

You must send entry and evidence. based_on and invocation work as for put. The response gives the new entry’s sequence number.

HTTP 200 or 201
{ "record": "incidents/inc-4471", "field": "symptoms", "seq": 1, "record_version": 4 }

STALE_PREMISE works the same as for put.

claim

Take, renew or release a lease on a task field.

PUT /v1/records/{class}/{id}/fields/{field}/claim

{ "lease_seconds": 300, "renew": true, "invocation": { "run": "r-91" } }

All three keys are optional. Send renew: true to extend a lease you already hold. Send lease_seconds: 0 to release it.

A claim is a write. It raises the field and record versions and leaves a history row, so it carries invocation like any other write. A takeover row then says which call displaced the agent that died.

HTTP 200 or 201
{
  "record": "incidents/inc-4471",
  "field": "remediation",
  "owner": "apikey:remediator",
  "lease_expires_at": "2026-10-01T22:05:39.012433-07:00",
  "version": 5,
  "record_version": 19,
  "took_over_from": "apikey:remediator2"
}

If another identity holds the task and its lease has not expired, you get HELD with the owner and the expiry.

HTTP 409 HELD
{ "error": "HELD", "field": "remediation", "owner": "apikey:remediator",
  "lease_expires_at": "2026-10-01T22:05:36.923106-07:00", "version": 1 }

Details:

  • If you leave out lease_seconds, the gateway uses the lease set on the field in the class. If the class sets none, lease_seconds is required.
  • The maximum lease is 86400 seconds.
  • renew: true on a field nobody holds works as an ordinary claim.
  • After a release, the response has no owner or lease_expires_at.
  • took_over_from appears only when you took over a lease that had expired.

checkpoint

Save your progress on a record, or load the last saved progress for your role.

To save: PUT /v1/records/{class}/{id}/checkpoint with { "document": { … } }. The document must include every section listed in the class’s checkpoint.sections.

HTTP 200
{ "record": "incidents/inc-4471", "role": "remediator", "checkpoint_id": 1, "record_version": 19 }

To load: GET /v1/records/{class}/{id}/checkpoint?role=remediator. The response has the latest document for the role and every field that changed since it was saved.

HTTP 200
{
  "record": "incidents/inc-4471",
  "role": "remediator",
  "document": { "goal": "…", "progress": "…", "decisions": [ … ], "open_questions": [ … ] },
  "record_version": 19,
  "changed_fields": [
    { "field": "planned_action", "version": 4, "author": "apikey:diagnoser", "evidence": "hypotheses@2" }
  ]
}

Details:

  • A checkpoint belongs to a role, not to an identity. When you save, the gateway uses your role. If your identity holds more than one role in the class, save returns MALFORMED.
  • When you load, role is required if your identity holds more than one role. It must be a role you hold.
  • If no checkpoint has been saved, document is null and record_version is 0. changed_fields then lists every field your role may read that has been written at least once. Task fields are never in changed_fields.
  • The class’s minIdentityStrength floor applies to a save. A credential below it gets WEAK_IDENTITY. A checkpoint is not a field, so the body carries required and presented but no field. A field’s own floor never applies to a checkpoint.

query

Find records in a class by field values.

GET /v1/records/{class}?status=open&limit=100&after=. Every query parameter except limit and after filters on a field’s current value. A record matches when every filter matches exactly. A filter value is compared as a JSON string. If it also parses as JSON, it is compared as that value too. So ?count=3 matches a number field holding 3, and ?done=true matches a boolean.

HTTP 200
{ "class": "incidents", "records": [ { "record": "incidents/inc-4471", "record_version": 21, "fields": { … } } ] }

Details:

  • Each record has the same shape as a get response, except that an append field carries only its entry count and latest entry.
  • limit defaults to 100. The maximum is 1000.
  • Records come back oldest first, by creation time.
  • One call is one page. When more records remain the response carries next_after, an opaque cursor. Pass it back as after to get the next page. An after the gateway did not issue is MALFORMED.

classes

List the classes you have a role in, or read one class’s definition.

GET /v1/classes returns { "classes": [ { "name": "incidents" } ] }. A class that has been deleted shows "gone": true.

GET /v1/classes/{name} returns the class as the gateway loaded it: name, namespace, fields, roles, ttl, checkpoint, idPattern and gone. You need a role in the class to read it.

history

Read every write to a record, including the rejected ones. This is what kslate history and kslate blame call.

GET /v1/records/{class}/{id}/history?field=planned_action&limit=1000&before=

HTTP 200
{
  "record": "incidents/inc-4471",
  "history": [
    {
      "seq": 16, "field": "planned_action", "op": "put", "version": 3, "record_version": 12,
      "value": "fail over, then scale payments-api to 6",
      "author": "apikey:diagnoser", "identity_strength": "shared-secret",
      "evidence": "hypotheses@2", "expected_version": 2,
      "based_on": [ { "field": "hypotheses", "version": 2 } ],
      "outcome": "applied", "created_at": "2026-10-01T22:00:36.864577-07:00"
    }
  ],
  "next_before": 9
}

Rows come newest first. Each row records what the writer sent and what happened to it.

Details:

  • op is one of put, append, claim, release or takeover.
  • outcome is one of applied, stale, stale_premise, held, scope_denied, weak_identity, evidence_required or identity_mismatch.
  • identity_strength is what the gateway derived for the author’s credential at the time of the write. invocation is what the writer sent, unchanged. Both are absent on rows older than the columns.
  • A based_on element with "kind": "fence" was declared to prove the writer still held a claim. kslate blame prints it as (fence).
  • inferred: true marks based_on the gateway worked out from your reads rather than ones you declared.
  • A rejected write has no version. It does carry record_version when the record already existed, which is the record’s version at the moment the write was refused.
  • limit defaults to 1000. When older rows remain, the response includes next_before. Pass it back as before to get the next page.
  • You only see rows for fields your role may read.

readyz

Check that the gateway is ready to serve.

GET /readyz is served on the API port and on the metrics port. It returns 200 when the database schema this binary expects is present. If the schema is missing, the gateway tries to apply it and returns 503.

Limits

LimitValue
Request body4 MiB
Record id256 bytes, no /, class idPattern
X-Kslate-Scope256 bytes
lease_seconds0 to 86400
invocation1 KiB. Over that, or not a JSON object, is MALFORMED
STALE diffcomputed when both values are under 64 KiB and 500 lines
Value kept for a scope, evidence or identity rejection64 KiB, then size and SHA-256. STALE and STALE_PREMISE rows keep the whole value
query limitdefault 100, maximum 1000 per page; follow next_after for the rest
history limitdefault 1000, maximum 10000
checkpoint documenta JSON object, at most 64 KiB. Over that, or not an object, is INVALID_CHECKPOINT
Inference window15 minutes
Unauthenticated budget50 responses/s per address, burst 200, then 429
Concurrent connections4096 (--max-conns)

gRPC

The same six operations are available over gRPC as kslate.v1.KslateService. Turn it on with --grpc-listen. Send your credential in the authorization metadata key as Bearer <token>, and the scope in x-kslate-scope.

RPCHTTP equivalent
Get(GetRequest)get
Put(PutRequest)put
Append(AppendRequest)append
Claim(ClaimRequest)claim
CheckpointSave, CheckpointLoadcheckpoint
Query(QueryRequest)query

The gateway runs the same checks and returns the same codes. A rejection is a gRPC error, and its details carry the same body as the HTTP error.

Details:

  • In protobuf, an empty repeated field looks the same as a missing one. So Put and Append have a declare_no_premises flag that means “infer nothing”. Setting it while also sending based_on is MALFORMED.
  • There is no RPC for history.
  • PutRequest, AppendRequest and ClaimRequest take invocation, a premise carries kind, and Field and Entry carry identity_strength and invocation — the same keys as HTTP.
  • The proto file is proto/kslate/v1/kslate.proto.

MCP

The gateway serves MCP over streamable HTTP at /mcp on the API port. Send the same Authorization and X-Kslate-Scope headers on the MCP request. There are six tools. Their descriptions are the ones agents see.

ToolDescription
kslate_getRead a shared record. Returns each field’s version; you need it to write.
kslate_putWrite one field. Pass the version you read. If rejected as STALE, another agent changed it since you read it: re-read and decide again. Optionally list the fields and versions your decision is based on; if any moved, the write is rejected as STALE_PREMISE.
kslate_appendAdd an entry to a list field. Evidence is required.
kslate_claimTake ownership of a task for a lease period. Renew before it expires. If HELD, someone else is working on it.
kslate_checkpointSave or load your progress so a replacement can continue.
kslate_queryFind records in a class by field values.

A rejection comes back as the tool’s error result with the same body as the HTTP error. UNAUTHENTICATED and RATE_LIMITED are the exceptions. They are answered on the transport, 401 and 429, before any protocol method runs. There is no tool for history. The write tools take invocation and a premise kind, and kslate_get returns identity_strength and invocation, the same as HTTP.

Metrics

Both the gateway and the worker expose Prometheus metrics, each on its own port. The labels name classes, fields and roles, so keep these ports off the agent network.

On the gateway’s metrics port, default :9100:

MetricMeaning
kslate_writes_total{class,field,outcome}Applied writes (put, append)
kslate_rejections_total{class,field,role,outcome}Rejected requests
kslate_claims_total{class,field,outcome}Claim attempts (claim, renew, release, takeover)
kslate_takeovers_totalSuccessful takeovers of an expired lease
kslate_write_latency_secondsLatency of every request that is not a GET, including MCP tool calls
kslate_checkpoints_total{class,role}Checkpoints saved

On the worker’s metrics port, default :9090. The worker computes these on its sweep, so they are absent from the gateway’s port:

MetricMeaning
kslate_records{class}Live records
kslate_history_rows{class}History rows
kslate_leases_lapsedTask fields with a lapsed lease nobody reclaimed
kslate_role_wrote_nothing{class,role}Live records on which no identity of the role has written

OpenTelemetry spans are not emitted. Every Prometheus metric above is.

When they land, each operation gets one span carrying the identity, the record, the field, the outcome, the expected and current versions and the premises, and the gateway propagates incoming trace context. Nothing emits that today.

The fault-injection measurements — blind retries, re-decisions, retry loops — are not kslate’s to compute. They are derived from history and from traces by whatever runs the experiment. kslate records what happened and leaves the counting to the caller.