Decider protocol¶
A decider is a small, fast model that answers bounded questions, such as "is this loop stuck on the same root cause as the last one?", so that Chalk does not have to ask Claude. Every decider Chalk talks to, the local reference service or a hosted endpoint, speaks the protocol on this page, so Chalk has one client for all of them.
This is version 1.
The shape, and why it is this one¶
The protocol is the HTTP API of
strands-decider, the
model the local reference service runs, POST /v1/systemone, plus two
things Chalk needs from every provider: a version field and a bearer
token. strands-decider serves it unchanged, so the reference service
needs no adapter, and a provider that wants to serve Chalk implements one
documented API rather than a Chalk-only one.
| Option | What Chalk would own | Chosen |
|---|---|---|
The strands-decider API, plus protocol and Authorization |
The client (lib/decider.sh) |
yes |
| A Chalk-specific API | The client, plus an adapter in front of strands-decider, kept in step with it | no |
Both additions fit the existing API without changing it: strands-decider ignores request fields it does not know, and a response with no version is read as version 1.
Endpoints¶
A decider is reached at a base URL, CHALK_DECIDER_URL.
| Method and path | Purpose | Required |
|---|---|---|
POST /v1/systemone |
Answer the questions about one text | yes |
GET /health |
Say that the model is loaded, and which | no; see Health |
Chalk checks health only from chalk decider up, chalk decider status,
chalk doctor, a run starting the local service, and office hours before
it writes a lesson's embedding; never during a loop. During a loop, a
decider that is down is found out by its first question.
Request¶
POST /v1/systemone HTTP/1.1
Content-Type: application/json
Authorization: Bearer <CHALK_DECIDER_TOKEN>
{
"protocol": 1,
"state": "previous loop: ...\ncurrent loop: ...",
"questions": {
"stuck": {
"type": "noul",
"instructions": "Is the agent stuck on the same root cause as in the previous loop?"
}
}
}
| Field | Type | Meaning |
|---|---|---|
protocol |
integer | The protocol version, 1. A server ignores it if it has only one version. |
state |
string | The text the questions are about. Never empty. |
questions |
object | One or more questions about state, by key. Keys are Chalk's own and come back in the response. |
Each question has a type and instructions, plus criteria for some
types:
type |
Asks | criteria |
|---|---|---|
noul |
Yes, no or unknown: is the statement in instructions true of state? |
Optional: {"true": "...", "false": "..."}, what each answer means |
choice |
Pick one of the named options | Required: {"option": "description", ...}, at least 2 |
score |
Rate on an ordered scale, from 0 (the first level) up | Required: ["lowest level", ..., "highest level"], 2 to 10 levels |
One request carries every question Chalk has about one text, so a decider can read the text once. Chalk sends at most 8 questions per request (the lesson rerank).
A server must ignore request fields it does not know, so that later versions can add optional fields without breaking it.
Response¶
200 OK with:
{
"protocol": 1,
"model": "strands-decider-2B-hobson-v19",
"answers": {
"stuck": {"type": "noul", "noul": 0.96}
},
"latency_ms": 141.2
}
| Field | Type | Meaning |
|---|---|---|
protocol |
integer | Optional; absent means 1. |
model |
string | Which model answered. Chalk records it with every decision. |
answers |
object | One answer per question key. A key that is missing is an unknown answer. |
latency_ms, usage |
Optional, ignored by Chalk, which measures time itself. |
Each answer has the type of its question:
type |
Fields | How Chalk reads it |
|---|---|---|
noul |
noul: the probability, 0 to 1, that the statement is true |
yes when noul ≥ 0.5, otherwise no, with confidence max(noul, 1 − noul) |
choice |
choice: the option picked; confidence: 0 to 1; probabilities: per option, optional |
the option, with confidence |
score |
score: the expected level, from 0; confidence: 0 to 1; probabilities, legend: optional |
the score, with confidence |
A noul answer has no separate confidence: with two outcomes, the
probability is the confidence. So noul = 0.96 is "yes, at 0.96", and
noul = 0.02 is "no, at 0.98".
Unknown. A decider that cannot answer a question leaves its key out.
Chalk records it as unknown, as it does an answer whose confidence is
below CHALK_DECIDER_THRESHOLD: neither ever changes a run.
Calibration. Chalk acts on an answer only at or above
CHALK_DECIDER_THRESHOLD, 0.9 by default. That is safe only if
confidence is calibrated: answers given at 0.9 should be right about 9
times in 10. strands-decider publishes its calibration (an expected
calibration error of 0.05; noul answers at 0.9 or more are right about
95% of the time). Every answer is recorded with what happened next, and
the report card shows how often confident answers were right.
Versions¶
- A request carries
"protocol": 1. - A response may carry
protocol. With none, it is version 1. - A response with any other version is treated as no answer, and
chalk decider statusandchalk doctorname both versions. - Adding an optional field, a question type or an endpoint does not change the version. Changing or removing anything does.
Authentication¶
When CHALK_DECIDER_TOKEN is set, every request carries
Authorization: Bearer <token>. Chalk never writes the token to a log,
the database or a process's arguments (curl reads the header from a file
descriptor). The local reference service listens on 127.0.0.1 only and
needs no token.
Errors and time¶
A decider is optional and never holds a run up. Anything other than a
200 with a readable body is no answer, and a loop with no answer
goes on exactly as with CHALK_DECIDER=off:
| What happened | Chalk records | Says so |
|---|---|---|
| Could not connect | unreachable |
no; the decider may simply not be running |
| Could not connect to the local service while it loads its model | starting |
no |
| No full answer within the time left | timeout |
no |
| The loop's time was already used up | budget, and sends nothing |
no |
| The local service was answering other loops for longer than this one could wait | busy, and sends nothing |
no |
401 or 403 |
auth |
a warning, once per run |
422 or another 4xx |
rejected |
no |
5xx |
server |
no |
Unreadable JSON, or no answers |
invalid |
no |
A protocol other than 1 |
version |
a warning, once per run |
Chalk never retries within a loop.
Concurrent requests. A fleet's loops may ask at the same time, so a
hosted decider should answer concurrent requests, or queue them within
the time each allows. The local reference service cannot (strands-decider
0.1.0 aborts on Apple's GPU when asked two things at once), so Chalk's
loops take turns at it: each waits at most 1 second for its turn, out of
its budget, and records busy if the turn does not come. See
Several loops at once.
Time is one budget per loop, not per call. All the calls one loop
makes to the host services, the stuck question, the lesson embedding and
the lesson rerank, share 2 seconds. Each call may take whatever is
left of it (curl's --max-time); once it is gone, the remaining calls are
skipped and recorded as budget. So no loop waits more than 2 seconds,
however many questions it asks.
A decider should answer one request in well under a second. strands-decider
publishes about 150 ms warm on an M3 Pro. chalk decider up measures the
local service: if its median is above 1 second, it may record in shadow
mode but cannot act (on) on that machine, and chalk doctor says why.
The questions Chalk asks¶
| Key | Type | Asked when | state |
|---|---|---|---|
stuck |
noul |
A loop failed and its verdict is spinning or other, the cases the fingerprint rules cannot settle. Not with CHALK_FP_RULES=off. |
The failing tests and first error of the previous and the current loop, and a diffstat between their working trees (file names and line counts, no contents), capped at about 1,500 tokens |
lesson_<id> |
noul |
Choosing which past lessons to give the next loop, once there are 30 resolved lessons (CHALK_DECIDER_MIN_LESSONS); up to 5 at once |
The current failure, then each candidate lesson's failure and fix in its question; 4,000 characters in all |
What each answer can do:
CHALK_DECIDER=shadow: nothing. The answers are recorded in thedecisionstable and shown on the report card.CHALK_DECIDER=on: ayestostuckat or above the threshold sends the run to detention at once. Ayestolesson_<id>at or above it keeps that lesson in the prompt. A decider can stop a run early; it can never give a run more loops.
What a decider receives¶
Only what the state and instructions above hold, and the bearer token:
| Question | state |
In the questions |
|---|---|---|
stuck |
For the failed loop and the one before: up to 20 failing test IDs and the first error line of each, then git diff --stat between their working trees (file paths and line counts, at most 41 lines). At most 6,000 characters. |
The fixed question |
lesson_<id> |
Current failure: and up to 1,500 characters of it: its first error and failing test IDs; before the first loop, the start of the spec; when the rubric gave neither, the failure reason and the last lines of the rubric's output, which can quote source lines from a stack trace |
Each of up to 5 past lessons: its failure (first error or signature) and the fix note someone wrote, shortened so the whole request is at most 4,000 characters |
Never the repository's files, the changes themselves, or the Claude credentials. Health checks send nothing of yours (see Health).
The local reference service runs on your machine, so with it nothing
leaves the machine. A decider on another machine gets nothing until
you acknowledge it. Any CHALK_DECIDER_URL (or CHALK_EMBED_URL)
whose host is not 127.0.0.0/8, localhost or ::1 is sent nothing, not
even a health check, until you run:
which prints exactly what that address would receive, as in the table
above, and records it as acknowledged. Until then a run goes on with the
decider off and says why, once; chalk doctor and chalk decider status
say so too, with the same list. chalk decider untrust URL takes it back.
Health¶
GET /health is optional. A decider that has it answers 200 with
JSON once the model is loaded, and only status is required:
strands-decider also reports its checkpoint and base model, which
chalk decider status shows with the revisions it resolved.
When /health answers 404 or 405, Chalk checks the decider with the
smallest real request instead: one POST /v1/systemone with a single
noul question about a fixed text, with the bearer token:
{"protocol": 1, "state": "Chalk health check.",
"questions": {"health": {"type": "noul", "instructions": "Is this text a health check?"}}}
A 200 with an answers object is healthy, whatever the answer. The
probe has 2 seconds of its own, outside any loop's budget, and at the
local reference service it waits its turn like any question (see
Concurrent requests). Anything else from /health,
such as a 503 while the model loads, or no connection, is not healthy,
and nothing is probed. So a decider need not implement /health; one
that does is checked without spending a question.
Embeddings¶
Semantic lesson recall needs embeddings, which are not part of the
decider protocol: strands-decider does not make them. The local reference
service runs a second, Chalk-owned process for them, chalk-embed, with
the OpenAI-compatible API:
{
"object": "list",
"model": "BAAI/bge-small-en-v1.5",
"data": [{"object": "embedding", "index": 0, "embedding": [0.0123, -0.0456, ...]}]
}
Vectors have 384 dimensions and unit length, so cosine distance is
1 − dot product. GET /health answers like the decider's, with the
model's dimensions and revision; it is optional too, and without it
Chalk embeds the one word health instead. Embedding calls share the
loop's 2-second budget.
chalk-embed receives the current failure, up to 4,000 characters (the
same text as the lesson_<id> question's, before it is cut to 1,500),
and, to store their embeddings, each resolved lesson's failure (up to
1,500 characters) and fix note. An embedding service at a
CHALK_EMBED_URL on another machine needs chalk decider trust like a
decider.
Testing a decider¶
Chalk's tests drive a fake decider, tests/fakes/curl, that implements
this page: it checks each request and answers it from a script, so a
change to the protocol fails the tests until the fake follows it. To
check your own service by hand: