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 inferredbased_on. - Request bodies are JSON, at most 4 MiB. Only
claimlets you omit the body. - Every error body has an
errorkey with the error code. Most also haverecordandfield. TheSTALEandHELDbodies carryfieldbut notrecord, because their shapes are fixed by the design. - Timestamps are RFC 3339 with the server’s offset.
Status codes
| Code | Meaning |
|---|---|
| 200 | Read, or write applied |
| 201 | Write applied and the record was created by it |
| 400 | MALFORMED (bad JSON, missing required key, bad type, enum violation, lease out of range, …) |
| 401 | UNAUTHENTICATED |
| 403 | NO_ROLE, SCOPE_DENIED, WEAK_IDENTITY, IDENTITY_MISMATCH |
| 404 | NOT_FOUND (record, or unknown class) |
| 409 | STALE, STALE_PREMISE, HELD |
| 410 | CLASS_GONE |
| 422 | EVIDENCE_REQUIRED, UNKNOWN_FIELD, NOT_WRITABLE, NOT_APPEND_FIELD, NOT_TASK_FIELD, INVALID_CHECKPOINT |
| 429 | RATE_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) |
| 500 | INTERNAL |
| 503 | UNAVAILABLE (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.
UNAUTHENTICATEDNOT_FOUND, when the class does not existNO_ROLECLASS_GONE(writes only)MALFORMED, when the record id does not match the classidPatternUNKNOWN_FIELDSCOPE_DENIEDWEAK_IDENTITY(put,append,claim, and acheckpointsave)NOT_WRITABLE/NOT_APPEND_FIELD/NOT_TASK_FIELDIDENTITY_MISMATCHHELDEVIDENCE_REQUIREDSTALESTALE_PREMISE
Two more checks happen inside that order:
- Other
MALFORMEDcases 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 betweenIDENTITY_MISMATCHandEVIDENCE_REQUIRED. - The checkpoint gate (
INVALID_CHECKPOINT) runs betweenSTALEandSTALE_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:
STALEcomes beforeSTALE_PREMISE. Either way you have to re-read and send a newexpected_version, so the target field’s own staleness is the more useful answer.WEAK_IDENTITYsits betweenSCOPE_DENIEDand 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 portget
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.
{
"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_strengthispod-bound,sa-bound,issuer-declaredorshared-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) andentries[]. Each entry hasseq,value,author,identity_strength,evidence,updated_at,based_onandinvocation. - A task field returns
ownerandlease_expires_atwhile 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": { … } }| Key | Versioned | Soft |
|---|---|---|
value | required, must match the field’s type and enum | required, must match type |
expected_version | required; 0 = must not exist | ignored |
evidence | required if the class says so | same |
based_on | optional; [] disables inference | same |
based_on[].kind | optional; fence marks a premise declared to prove the writer still holds a claim (based_on) | same |
invocation | optional; a JSON object of at most 1 KiB saying which call produced the write. Stored and returned unchanged, never checked | same |
A successful write returns the new version of the field.
{ "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.
{
"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.
{
"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.
{ "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
STALEbody hascurrent_value,current_author,current_evidenceandcurrent_updated_atnull, anddiffempty. diffis replaced by a short note when either value is over 64 KiB or 500 lines.- In a
STALE_PREMISEbody, the top-levelfield,current_versionandcurrent_authoralways describe the field you were writing, whether or not it moved. If it does not exist yet,current_versionis 0 andcurrent_authoris 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.
{ "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.
{
"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.
{ "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 theleaseset on the field in the class. If the class sets none,lease_secondsis required. - The maximum lease is 86400 seconds.
renew: trueon a field nobody holds works as an ordinary claim.- After a release, the response has no
ownerorlease_expires_at. took_over_fromappears 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.
{ "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.
{
"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,
roleis required if your identity holds more than one role. It must be a role you hold. - If no checkpoint has been saved,
documentisnullandrecord_versionis 0.changed_fieldsthen lists every field your role may read that has been written at least once. Task fields are never inchanged_fields. - The class’s
minIdentityStrengthfloor applies to a save. A credential below it getsWEAK_IDENTITY. A checkpoint is not a field, so the body carriesrequiredandpresentedbut nofield. 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.
{ "class": "incidents", "records": [ { "record": "incidents/inc-4471", "record_version": 21, "fields": { … } } ] }Details:
- Each record has the same shape as a
getresponse, except that an append field carries only its entry count and latest entry. limitdefaults 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 asafterto get the next page. Anafterthe gateway did not issue isMALFORMED.
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=
{
"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:
opis one ofput,append,claim,releaseortakeover.outcomeis one ofapplied,stale,stale_premise,held,scope_denied,weak_identity,evidence_requiredoridentity_mismatch.identity_strengthis what the gateway derived for the author’s credential at the time of the write.invocationis what the writer sent, unchanged. Both are absent on rows older than the columns.- A
based_onelement with"kind": "fence"was declared to prove the writer still held a claim.kslate blameprints it as(fence). inferred: truemarksbased_onthe gateway worked out from your reads rather than ones you declared.- A rejected write has no
version. It does carryrecord_versionwhen the record already existed, which is the record’s version at the moment the write was refused. limitdefaults to 1000. When older rows remain, the response includesnext_before. Pass it back asbeforeto 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
| Limit | Value |
|---|---|
| Request body | 4 MiB |
| Record id | 256 bytes, no /, class idPattern |
X-Kslate-Scope | 256 bytes |
lease_seconds | 0 to 86400 |
invocation | 1 KiB. Over that, or not a JSON object, is MALFORMED |
STALE diff | computed when both values are under 64 KiB and 500 lines |
| Value kept for a scope, evidence or identity rejection | 64 KiB, then size and SHA-256. STALE and STALE_PREMISE rows keep the whole value |
query limit | default 100, maximum 1000 per page; follow next_after for the rest |
history limit | default 1000, maximum 10000 |
| checkpoint document | a JSON object, at most 64 KiB. Over that, or not an object, is INVALID_CHECKPOINT |
| Inference window | 15 minutes |
| Unauthenticated budget | 50 responses/s per address, burst 200, then 429 |
| Concurrent connections | 4096 (--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.
| RPC | HTTP equivalent |
|---|---|
Get(GetRequest) | get |
Put(PutRequest) | put |
Append(AppendRequest) | append |
Claim(ClaimRequest) | claim |
CheckpointSave, CheckpointLoad | checkpoint |
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
PutandAppendhave adeclare_no_premisesflag that means “infer nothing”. Setting it while also sendingbased_onisMALFORMED. - There is no RPC for history.
PutRequest,AppendRequestandClaimRequesttakeinvocation, a premise carrieskind, andFieldandEntrycarryidentity_strengthandinvocation— 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.
| Tool | Description |
|---|---|
kslate_get | Read a shared record. Returns each field’s version; you need it to write. |
kslate_put | Write 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_append | Add an entry to a list field. Evidence is required. |
kslate_claim | Take ownership of a task for a lease period. Renew before it expires. If HELD, someone else is working on it. |
kslate_checkpoint | Save or load your progress so a replacement can continue. |
kslate_query | Find 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:
| Metric | Meaning |
|---|---|
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_total | Successful takeovers of an expired lease |
kslate_write_latency_seconds | Latency 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:
| Metric | Meaning |
|---|---|
kslate_records{class} | Live records |
kslate_history_rows{class} | History rows |
kslate_leases_lapsed | Task 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.