kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
116 lines (88 loc) • 5.62 kB
Markdown
# The agent JSONL protocol — argument & response shapes
`kestrel agent` is the machine face: it reads one JSON **request envelope** per line on stdin
(`{ "op": <name>, ...args }`) and writes one versioned **response object** per line on stdout
(`{ "v": "kestrel.agent/v1", "kind": <op>, "value": <payload> }`). Diagnostics — including typed
refusals — go to **stderr**; the process exits nonzero on the first refusal.
You do not have to reverse-engineer the argument shapes. The protocol is **self-describing**: pipe a
single `describe` request in and it emits every op's argument and response shape on the wire.
```sh
echo '{"op":"describe"}' | kestrel agent
```
`describe` needs no session. Its `value` is `{ "protocol": "kestrel.agent/v1", "ops": AgentOpSchema[] }`,
one entry per op — the exact same catalogue rendered below and by `kestrel agent --help`. The wire copy
is generated from the handler's own schema, so it can never drift from what the CLI actually accepts.
## The `{ kind: … }` argument objects
These are the object shapes an integrator most often has to get right. A string where an object is
expected — or the wrong field name — is refused with code `bad-arg`, and the refusal **names** the
expected shape.
### `openSession.subject` — a `SessionRef`
```jsonc
{ "kind": "catalog", "id": "<catalog entry id>" } // a pinned catalog subject
{ "kind": "dataset", "source": "<plan text>", "dataset": "<dataset ref>" } // a raw dataset (managed backend)
```
A bare string (`"subject": "choppy-1101-strict"`) is **rejected** — `subject` must be an object with a
string `kind`. Discover the available catalog ids with the `catalog` op.
`openSession` also accepts an optional `document` (a string): the customer strategy (Kestrel plan text)
the managed author-no-strategy fence requires for a catalog subject. The LOCAL transport already carries
the pinned recipe's strategy and ignores it.
### `advance.response` / `revise.response` — an `AuthoredResponse`
```jsonc
{ "kind": "authored", "document": "<Kestrel plan text>" } // author at the pending slot
{ "kind": "pass" } // stand pat — standing Plans keep managing
{ "kind": "stand-down", "reason": "<why>" } // an explicit, gradable stand-down
```
`document` is the **only** accepted authored-body field name — `text`, `bytes`, `source`, and `content`
are all rejected.
### `submit.response` — a `BoundResponse`
```jsonc
{ "sessionId": "<id>", "ordinal": <n>, "parentHash": "<hash>", "frameRoot": "<hash>", "body": <TurnBody> }
```
The explicit-binding turn: the full pentad header plus a `TurnBody`. Field-level reconciliation against
the recorded slot is the Session's job.
### `resume.after` / `resumeOperation.after` — an optional `EventCursor`
```jsonc
{ "sequence": <n>, "token": "<opaque>" }
```
Omit it to resume from the start. A present-but-malformed cursor is a `bad-arg` refusal (never silently
ignored).
## The op set
| op | session? | args | emits (`value`) |
| --- | --- | --- | --- |
| `describe` | no | — | `{ protocol, ops: AgentOpSchema[] }` |
| `catalog` | no | — | `CatalogEntry[]` |
| `validate` | no | `document` | `ValidateOutcome { ok, diagnostics }` |
| `openSession` | no | `subject`, `document?` | `{ gated:false, sessionId } \| { gated:true, payment }` |
| `start` | yes | — | `Delivery` |
| `advance` | yes | `response` (`AuthoredResponse`) | `SessionResponse` |
| `revise` | yes | `response` (`AuthoredResponse`) | `SessionResponse` |
| `submit` | yes | `response` (`BoundResponse`) | `SessionResponse` |
| `resume` | yes | `after?` (`EventCursor`) | `SessionTranscript` |
| `finalize` | yes | — | `{ blotter, sessionId, tipHash, conformanceRoot, artifacts }` |
| `grade` | no | `blotters` (`string[]`) | `Gated<GradeOutcome>` |
| `artifact` | no | `ref` | `ArtifactResult { ref, kind, value }` |
| `resumeOperation` | no | `operationId`, `after?` | `OperationResumption` |
Session verbs (`start` … `finalize`) require a prior `openSession` in the same stream; issued first they
refuse with code `no-session`. An unknown op refuses with code `unknown-op` and lists the valid ops.
## View addressing — deliberately not on this wire (today)
No op or argument shape carries a rendered-View selection or a seat: `openSession` takes a subject and
an optional document, not a harness config, and `session/frame` deliveries are the **typed** frame
struct — rendering it (and therefore choosing its View) is the client's concern, off the wire. The one
View-shaped thing that does travel here is *authored text*: a Wake's `DELIVER <view>` clause inside an
`advance`/`revise` document. The role-keyed axis (`AgentConfig.seatViews`, the founder-View opt-in) has
no protocol carrier; it is reachable only by programs that construct an `AgentConfig` directly (see the
[API reference](./api-reference.md)). This is deliberate fail-closed scope, not an omission — a remote
View projection would be a new contract route, and none exists; a client asking for one is refused, not
silently given default panes as if it had been honored.
## A minimal LOCAL session
```sh
kestrel agent <<'EOF'
{"op":"describe"}
{"op":"catalog"}
{"op":"openSession","subject":{"kind":"catalog","id":"<an id from catalog>"}}
{"op":"start"}
{"op":"advance","response":{"kind":"pass"}}
{"op":"finalize"}
EOF
```
No `--api` runs everything in-process (no network); `--api default` (or `KESTREL_API`) routes the same
request stream to the managed remote, byte-identical protocol objects across transports.