Skip to content
Your first agent

Your first agent

You write one function. It receives the record’s fields and returns what should be recorded. The client does the reading, the writing and the bookkeeping.

If you already have an agent, this is still the page: your code goes inside that function, and nothing about it changes.

Before you start

A gateway, which the install starts for you:

make run

It serves the starter class and writes two keys: diag-token for the diagnoser, rem-token for the remediator.

And the client, which is not on PyPI yet and has no dependencies:

pip install -e client/python

The starter class, incidents, has a planned_action field that the diagnoser writes and an actions_taken field that the remediator appends to. This agent plays the remediator. Give it a plan to act on:

curl -s -X PUT http://127.0.0.1:8080/v1/records/incidents/inc-7/fields/planned_action \
  -H 'Authorization: Bearer diag-token' \
  -d '{"value":"restart payments-api pods","expected_version":0,"evidence":"api pods in CrashLoopBackOff"}'

The agent

Save this as first_agent.py:

from kslate import Agent, Client, Write, run

RECORD = "incidents/inc-7"


def decide(fields):
    plan = fields["planned_action"]
    print(f"plan is {plan['value']!r} at v{plan['version']}")
    action = f"executed: {plan['value']}"          # your agent goes here
    return [Write(op="append", field="actions_taken",
                  entry=action, evidence="kubectl rollout restart deploy/payments-api")]


client = Client("http://127.0.0.1:8080", api_key="rem-token")
run(Agent(decide=decide), client, record=RECORD)
print("written")
python3 first_agent.py
plan is 'restart payments-api pods' at v1
written

That is the whole integration. What each part is:

  • decide(fields) is your agent. It gets every field of the record as a dict, in the shape the gateway returns, and returns a list of writes — or an empty list when there is nothing to do. Your model call goes inside it.
  • Write is one write you want made. op is put or append. An append takes entry and evidence; a put takes value, and the client fills in expected_version from what it read.
  • Client holds the gateway URL and your credential. Here the identity is apikey:remediator, which the incidents class allows to append to actions_taken.
  • Agent is configuration, not a class to subclass. decide is the only field it needs. Its reads and writes lists document intent for a reader of your code; the gateway enforces scope, and the client does not keep a second copy of that check.
  • run() reads the record, calls decide, and submits the writes with based_on set to the versions it read. You write no version check, no based_on list and no retry loop.

What landed

KSLATE_APIKEY=rem-token bin/kslate get --server http://127.0.0.1:8080 incidents/inc-7
incidents/inc-7  (record_version 2)
actions_taken        v1  (1 entries)
    #1  apikey:remediator    "executed: restart payments-api pods"
planned_action       v1  apikey:diagnoser     "restart payments-api pods"

Your entry carries who wrote it. It also carries the evidence you gave and the version of planned_action it was based on, which blame shows.

Make the gateway reject a write

A second agent can change the plan while your agent is deciding. The gateway then rejects the write your agent was about to make, because it was based on a plan that is no longer current.

Save this as rejected_agent.py. The decide function is the one you already wrote. What is new is a second client, holding the diagnoser’s key, that changes the plan on the first call:

from kslate import Agent, Client, Write, run

RECORD = "incidents/inc-7"
SERVER = "http://127.0.0.1:8080"

diagnoser = Client(SERVER, api_key="diag-token")
calls = 0


def decide(fields):
    global calls
    calls += 1
    plan = fields["planned_action"]
    print(f"decide #{calls}: planned_action v{plan['version']} {plan['value']!r}")

    if calls == 1:
        # Stands in for the other agent. A real diagnoser writes this on
        # its own schedule. Here it is timed so the rejection is certain.
        diagnoser.put(RECORD, "planned_action", "roll back api deployment",
                      expected_version=plan["version"],
                      evidence="api logs show config parse error at 14:02:11")

    return [Write(op="append", field="actions_taken",
                  entry=f"executed: {plan['value']}", evidence="kubectl output")]


client = Client(SERVER, api_key="rem-token")
run(Agent(decide=decide), client, record=RECORD)
print("written")
python3 rejected_agent.py
decide #1: planned_action v1 'restart payments-api pods'
decide #2: planned_action v2 'roll back api deployment'
written

Your function ran twice, on two different plans. What happened between those two lines:

  1. decide read planned_action at v1 and returned an append. run() sent it with based_on: planned_action@1.
  2. The plan was at v2 by then. The gateway rejected the append with STALE_PREMISE, and named the field that moved, its new version and who moved it.
  3. run() read the record again and called decide with v2. That append carried based_on: planned_action@2, and the gateway applied it.

Nothing recorded an action against a plan that had been replaced. Your function never saw the rejection, and you wrote no retry loop. run() re-reads and re-decides up to five times, which max_attempts sets.

decide returns writes instead of performing them, so a second call costs one more decision. If your function also acts — restarts something, calls an API, spends money — a second call acts again. Pass max_attempts=1 and the rejection reaches your code instead. When a field changes mid-decision has that code, and the other four errors a client raises.

Which record

incidents/inc-7 is an id you choose. Most agents are triggered by something that already names the work — a webhook, a queue message, a ticket — so use that identifier, and one that survives your own retries so a retry lands on the same record. The first write creates the record; nothing creates it in advance.

If nothing names it, ask:

res = client.query("incidents", filters={"status": "open"})
for r in res["records"]:
    print(r["record"])

Next

  • The agent loop runs this over a whole class, with many copies sharing the work.
  • When a field changes mid-decision covers the rest of the rejection you just saw: the five errors a client raises, and what to do when your agent acts as it decides.