Skip to content
The same exchange on the wire

The same exchange on the wire

This page is optional. Your first agent does the same job in Python in less time; read this one if you are not using a client library, or if you want to see the protocol itself.

You act as two agents sharing one incident record. The diagnoser writes a plan and the remediator acts on it. In between, the plan changes, so kslate rejects the remediator’s write and tells it what moved.

Before you start

You need Go 1.26 and Docker.

  1. Clone the repository and start a gateway.

    git clone https://github.com/kslate-dev/kslate && cd kslate
    make run

    The gateway is on 127.0.0.1:8080, with one API key per role. Each token maps to a name, so diag-token becomes the identity apikey:diagnoser and rem-token becomes apikey:remediator.

  2. In a second shell, set up the two identities.

    export B=http://127.0.0.1:8080
    export DIAG='Authorization: Bearer diag-token'
    export REM='Authorization: Bearer rem-token'

The class you are writing to

make run loads examples/contexts.yaml, the starter class, which is called incidents. A class declares the fields a record may have, how each may be written, and which identities may write which. Nothing in this guide creates it, and you cannot write a field the class does not declare.

These are the parts of incidents this guide uses, with the other fields and roles cut out:

kind: SharedContext
metadata: { name: incidents }          # the class name, and the first path segment below
spec:
  fields:
    planned_action: { type: string, mode: versioned, evidence: required }
    actions_taken:  { type: append }
  roles:
    diagnoser:  { identities: [apikey:diagnoser],  read: ["*"], write: [planned_action] }
    remediator: { identities: [apikey:remediator], read: ["*"], write: [actions_taken] }

Every name and rule in the steps below comes from that block.

Which line of the class causes what
In the commands belowBecause the class says
/v1/records/incidents/inc-1the class is named incidents; inc-1 is a record id you pick
planned_action takes expected_versionmode: versioned
planned_action needs evidenceevidence: required
actions_taken is written with POST, not PUTtype: append adds entries instead of replacing a value
diag-token may write planned_action, rem-token may notthe write list on each role

The real class has eight fields and six roles. Design a class covers writing your own.

The same class works on Kubernetes

kind: SharedContext is not a file format. It is the Kubernetes custom resource, and the same spec either way: off Kubernetes you put the document in a file and pass --policy, on Kubernetes you kubectl apply it. One decoder and one set of rules read both, so nothing you learn here is thrown away when you move to a cluster. Only metadata.namespace and status behave differently, and neither appears above.

The diagnoser writes a plan

The first write to a record creates it. planned_action is a versioned field that requires evidence. expected_version: 0 means “this field must not exist yet”.

curl -s -X PUT $B/v1/records/incidents/inc-1/fields/planned_action -H "$DIAG" \
  -d '{"value":"restart payments-api pods","expected_version":0,"evidence":"api pods in CrashLoopBackOff"}'
HTTP 201
{
  "record": "incidents/inc-1",
  "field": "planned_action",
  "version": 1,
  "record_version": 1
}

The response is 201: the write created the record.

The remediator reads the plan

The remediator reads the field it is about to act on. Note the version it gets back.

curl -s "$B/v1/records/incidents/inc-1?fields=planned_action" -H "$REM"
HTTP 200
{
  "record": "incidents/inc-1",
  "record_version": 1,
  "fields": {
    "planned_action": {
      "value": "restart payments-api pods",
      "version": 1,
      "author": "apikey:diagnoser",
      "evidence": "api pods in CrashLoopBackOff",
      "updated_at": "2026-10-01T22:34:15.401558-07:00"
    }
  }
}

Note "version": 1. The remediator will use that.

The diagnoser changes the plan

The diagnoser sends the version it holds, 1, and a new value.

curl -s -X PUT $B/v1/records/incidents/inc-1/fields/planned_action -H "$DIAG" \
  -d '{"value":"roll back api deployment","expected_version":1,"evidence":"api logs show config parse error at 14:02:11"}'
HTTP 200
{
  "record": "incidents/inc-1",
  "field": "planned_action",
  "version": 2,
  "record_version": 2
}

planned_action is now at version 2.

The remediator acts on the plan it read

The remediator did not see the change. It records its action and says what it was based on: planned_action at version 1.

curl -s -X POST $B/v1/records/incidents/inc-1/fields/actions_taken/entries -H "$REM" \
  -d '{"entry":"restarted payments-api pods","evidence":"kubectl rollout restart deploy/payments-api","based_on":[{"field":"planned_action","version":1}]}'
HTTP 409
{
  "current_author": null,
  "current_version": 0,
  "error": "STALE_PREMISE",
  "field": "actions_taken",
  "moved": [
    {
      "current_author": "apikey:diagnoser",
      "current_evidence": "api logs show config parse error at 14:02:11",
      "current_version": 2,
      "field": "planned_action",
      "your_version": 1
    }
  ],
  "record": "incidents/inc-1"
}

kslate rejected the write. Look at moved. The remediator read planned_action at version 1. It is now at version 2, changed by the diagnoser with new evidence. The action was not recorded. The attempt is in history.

The remediator reads again and acts on the new plan

curl -s "$B/v1/records/incidents/inc-1?fields=planned_action" -H "$REM"
curl -s -X POST $B/v1/records/incidents/inc-1/fields/actions_taken/entries -H "$REM" \
  -d '{"entry":"rolled back api deployment","evidence":"kubectl rollout undo deploy/payments-api","based_on":[{"field":"planned_action","version":2}]}'
HTTP 200
{
  "record": "incidents/inc-1",
  "field": "actions_taken",
  "seq": 1,
  "record_version": 3
}

This write names version 2, which is current, so it is accepted.

Look at the history

kslate blame shows every version of one field and every write that was refused because of it.

KSLATE_APIKEY=diag-token bin/kslate blame --server $B incidents/inc-1 planned_action
planned_action  (versioned, evidence required)
v2  22:34:15  apikey:diagnoser      "roll back api deployment"
      evidence: api logs show config parse error at 14:02:11
v1  22:34:15  apikey:diagnoser      "restart payments-api pods"
      evidence: api pods in CrashLoopBackOff
      superseded by v2 after 0s
--  22:34:15  apikey:remediator     REJECTED STALE_PREMISE
      write: actions_taken  based_on: planned_action@1  (moved to v2 at 22:34:15)

Each version of planned_action is listed with who wrote it and what evidence they gave. The last two lines are the write that was refused and the reason: it relied on version 1, which had moved to version 2.

kslate history shows the same for the whole record, every field at once, and kslate verify reports which rejections fired and which roles never wrote. Both are on the blame page.

What happened

  1. The diagnoser created the record and wrote planned_action version 1.
  2. The diagnoser changed it to version 2, passing version 1 to prove it had seen the current value.
  3. The remediator wrote an action based on version 1. kslate compared that with the current version inside the write and rejected it.
  4. The remediator read the new plan and wrote an action based on version 2. kslate accepted it.

Nothing sequenced those two agents. There was no leader and no queue: each acted when you told it to, and the gateway refused the one write whose premise had moved. kslate did not retry, merge or wait on anyone’s behalf. It rejected one write and told the writer what to look at.

Next

Your first agent is this scenario as a thirty-line Python program: you write one function that receives the record and returns what to write, and the client fills in based_on and re-asks your function when a write is rejected. There is a Go version too.

Every output on this page comes from a real run against the current build. The JSON is re-indented and nothing else is changed.