Protocol
On this page
Status: v1 · Schema: protocol/loop-event.schema.json
One runAgent() turn emits an ordered sequence of typed LoopEvent
objects. That sequence — not any particular transport — is the actual
contract. The SSE stream, the plain HTTP response, and the CLI's --json
mode are three different ways of delivering the exact same objects; a
client written against one of them can switch to another without
changing how it interprets a single event.
This document specifies that sequence: every event's shape, when it
fires, what's guaranteed about ordering, and how each transport carries
it. If you're building a UI or integration against loopengine in any
language — not just TypeScript — this is what to implement against
instead of reverse-engineering core/loop-events.ts.
Stability
This is v1. Within v1:
- Additive is safe to rely on being additive. A new event
typemay be introduced; a new optional field may be added to an existing event. A consumer that ignores unrecognizedtypevalues and unrecognized fields will never break. - These are breaking changes, and won't happen inside v1: renaming or
removing an event
type; renaming, removing, or changing the meaning of an existing field; turning an optional field required; changing an established transport's framing (SSE'sevent: <type>\ndata: <json>, the plain response'seventsarray, or--json's one-object-per-line NDJSON). - A breaking change ships as v2, documented as a new section here, not a silent edit to this one.
Lifecycle
Every event has a type field, which doubles as the discriminant and,
on the SSE transport, the literal frame name. Two families:
- Engine events — emitted by
runAgent()itself, identically regardless of which transport is carrying them. - Adapter events — synthesized by the HTTP/CLI adapter around a turn
(
session,approval:pending,question:pending,done,error).runAgent()itself has no notion of sessions or HTTP-level failures; these exist because a real caller needs them.
A turn always starts with session (streamed) and always ends with
exactly one done. The only real branch is the ActAuth Gate's decision
on each requested tool call:
session → budget:check → assistant:text? → actauth:decision
│
┌────────────────────────┴───────────────────────┐
│ allow │ deny
▼ ▼
[approval:pending first, tool:result (statusText: "Denied.")
if the rule said "ask"] — no execution, no tool:started.
│ Any sibling call already approved
▼ in the same batch gets loop:skipped
tool:started + its own tool:result ("Skipped.")
▼ │
toollane:result │
▼ │
tool:result (Approved./Error.) │
│ │
└─────────────────┬──────────────────────────────┘
▼
loops back to budget:check for the
next tool call, or the model's next turn,
until exactly one of:
loop:done / loop:max_turns / loop:denied
▼
done
approval:pending/question:pending are the only events without a
fixed position in that diagram — they can arrive at any point a tool call
is being evaluated. At most one is ever pending at a time (the Gate
evaluates one call before starting the next), so a consumer never needs
to track more than one open decision per turn.
Event catalog
Engine — emitted by runAgent()
type |
Fires | Fields |
|---|---|---|
budget:check |
Once per model call, before it | action: 'ok'|'nudge'|'over_hard_limit', usedTokens, budgetTokens, ratio, nudge? |
prompt:compaction |
A prompt was rejected as too large | from, to (message-array lengths, not tokens) |
assistant:text |
The model's "I'll do X" preamble alongside a tool call | text |
actauth:decision |
Once per requested tool call | tool, decision: 'allow'|'ask'|'deny', reason |
tool:started |
The instant a call is decided, before it runs | id, tool, args, detailText |
tool:result |
A call resolves — allowed, denied, or skipped | id, tool, args?, detailText?, statusText |
toollane:result |
ToolLane's own raw outcome, alongside tool:result |
name, summary |
skill:loaded |
A Skill block resolved to a real skill body |
skill |
loop:skipped |
A sibling call never ran because another was denied | name, deniedTools |
loop:max_turns |
Hit config.maxTurns with no final answer |
maxTurns |
loop:denied |
A human denied a call — the turn stops | deniedTools |
loop:done |
A genuine finish — no more tool calls | text |
Adapter — synthesized around a turn
type |
Fires | Fields |
|---|---|---|
session |
First event of every streamed turn | sessionId |
approval:pending |
A tool call needs a live human decision | id, tool, args, scope: {tenant,environment,agent}, reason, requestedAt |
question:pending |
The ask_user tool raised a real question |
id, question, options?, agent, sessionId?, requestedAt |
done |
The turn is over — wraps loop:done/max_turns/denied |
text, stopReason?: 'max_turns'|'denied' |
error |
Something failed after the response was already committed | error |
Full field types, required vs optional, and additionalProperties: false
enforcement live in protocol/loop-event.schema.json
— the table above is a summary, the schema is the source of truth.
Transports
SSE stream — live, frame by frame
POST /agents/:name/messages/stream
content-type: application/json
{"message": "order A-1001 arrived broken, can you refund it?"}
Each frame is event: <type>\ndata: <json>\n\n, <json> being the whole
event object (type included, redundantly with the frame name — parse
whichever is more convenient):
event: tool:started
data: {"type":"tool:started","id":"call_1","tool":"issue_refund","args":{"orderId":"A-1001"},"detailText":"matched rule 'refunds-need-approval'"}
event: approval:pending
data: {"type":"approval:pending","id":"3f9c...","tool":"issue_refund","args":{"orderId":"A-1001"},"scope":{"tenant":"default","environment":"production","agent":"customer-service"},"reason":"matched rule 'refunds-need-approval'","requestedAt":"2026-08-28T01:34:32.193Z"}
Plain HTTP — one request, the full array
POST /agents/:name/messages
content-type: application/json
{"message": "..."}
Two response shapes, both carrying the identical events array a
streamed version of the same turn would have delivered live:
200 — the turn finished:
{
"text": "Refunded $42.00 to your original payment method.",
"sessionId": "5a83c6b4-...",
"events": [ /* every LoopEvent this turn produced, in order */ ],
"stopReason": "max_turns"
}
stopReason is present only for a synthetic finish (max_turns/denied).
202 — the turn needs a decision right now:
{
"pending": true,
"type": "approval",
"id": "3f9c...",
"sessionId": "5a83c6b4-...",
"tool": "issue_refund",
"args": { "orderId": "A-1001" },
"scope": { "tenant": "default", "environment": "production", "agent": "customer-service" },
"reason": "matched rule 'refunds-need-approval'",
"approveUrl": "/approvals/3f9c.../approve",
"denyUrl": "/approvals/3f9c.../deny",
"events": [ /* everything so far */ ],
"statusUrl": "/agents/customer-service/sessions/5a83c6b4-..."
}
(type: "question" carries question/options/answerUrl instead.)
CLI — one JSON object per line
loopengine run customer-service --json "order A-1001 arrived broken, can you refund it?"
{"type":"session","sessionId":"5a83c6b4-..."}
{"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":100000,"ratio":0.00002}
{"type":"tool:started","id":"call_1","tool":"issue_refund", ...}
{"type":"done","text":"Refunded $42.00..."}
Every line is a complete LoopEvent, pipeable straight into jq or
anything else that reads NDJSON. Without --json, stdout stays
human-readable (just the final answer) and events go to stderr instead.
Resolving a pending item
POST /approvals/:id/approve POST /approvals/:id/deny
POST /questions/:id/answer {"answer": "..."}
All three return the exact same two-shape family as plain
POST /messages above (200 done, or 202 the next pending item, if the
turn needed another decision) — a caller can treat send → approve/deny/
answer → approve/deny/answer → ... as one uniform loop regardless of
which endpoint produced the response.
Validating against the schema
import Ajv2020 from 'ajv/dist/2020.js'
import addFormats from 'ajv-formats' // schema uses format: 'date-time'
import schema from './protocol/loop-event.schema.json' with { type: 'json' }
const ajv = new Ajv2020({ strict: true })
addFormats(ajv)
const validate = ajv.compile(schema)
validate(event) // true/false; validate.errors on failure
The schema is hand-maintained alongside core/loop-events.ts, not
generated from it — a PR that changes one without the other is
incomplete. It's plain JSON Schema (draft 2020-12), so it works from any
language with a validator, not just JavaScript.
Reference implementations
core/client.ts— the framework-agnostic TypeScript client this spec was extracted from: SSE parsing, the plain-request helpers, approve/deny/answer. Re-exported through the package's own entry point.examples/chatbox/react/,examples/chatbox/vue/— a hook/composable and a chat component each, built oncore/client.ts, renderingapproval:pending/question:pendingas real UI.
Implementing this protocol in another language means reproducing what
core/client.ts does: parse whichever transport you chose into
LoopEvent objects per this schema, and drive your own UI or logic off
type.