Request and the Verb-Dispatch DSL
khive exposes one MCP tool: request. Its ops argument holds one or more verb calls. The tool parses those calls, routes each verb to the loaded pack, and returns an outcome for every operation.
One tool keeps the MCP surface stable while packs can add or remove verbs. A client only needs to call request; it selects the operation in ops. Use the API reference for the current verb catalog and each verb’s parameters. This page covers how to compose calls.
Choose an input form
The function-call form is compact when writing a call directly:
request(ops="search(kind=\"entity\", query=\"LoRA\")")
The JSON form represents the same operation when a client is already building structured JSON:
request(ops="{\"tool\":\"search\",\"args\":{\"kind\":\"entity\",\"query\":\"LoRA\"}}")
Both forms produce the same operation for non-chain work. JSON may be a single object as above or an array of objects. Function-call form is generally easier to read and write by hand; JSON avoids manual string construction for deeply nested argument values.
Quote values as JSON
Argument values use JSON literals even in the function-call form. Strings are always double-quoted:
get(id="abc")
Not:
get(id=abc)
Numbers, booleans, null, arrays, and objects use their normal JSON syntax. Argument names remain unquoted identifiers.
Run independent operations in parallel
Wrap independent function-call operations in [...], separated by commas:
request(ops="[stats(), memory.recall(query=\"attention cache behavior\", limit=3)]")
The equivalent JSON batch is:
request(ops="[{\"tool\":\"stats\",\"args\":{}},{\"tool\":\"memory.recall\",\"args\":{\"query\":\"attention cache behavior\",\"limit\":3}}]")
A batch accepts at most 100 operations. Its operations run concurrently, with no ordering guarantee; results are returned in input order. Use it for work that has no dependency between operations. JSON form is always single or parallel, so it is also limited to independent operations.
Run parallel units, each its own chain
A comma-separated element inside [...] may itself be a |-chain. Each such element is a unit: its own ops run in order, sharing a $prev scope that never crosses into another unit, while the units themselves run concurrently with each other, the same way an ordinary batch does:
request(ops="[create(kind=\"entity\", entity_kind=\"concept\", name=\"A\") | link(source_id=$prev.id, target_id=\"<doc-id>\", relation=\"introduced_by\"), stats(), create(kind=\"entity\", entity_kind=\"concept\", name=\"B\") | link(source_id=$prev.id, target_id=\"<doc-id>\", relation=\"introduced_by\")]")
Here the two create-then-link pairs run as independent units while stats() runs alongside them as a one-op unit; an ordinary flat batch is the case where every unit happens to have exactly one op. $prev inside a unit resolves only against that unit’s own immediately preceding op, so a unit’s first op can never reference $prev, and no op can reference another unit’s result. A failing op aborts the rest of its own unit only; sibling units keep running. Results are one flat list in source order; each entry adds op_index, unit_index, and step_index alongside the usual ok/tool fields, so a caller can regroup the flat list back into its units without re-deriving the partition. Nesting stops at one bracket level: a [...] cannot appear inside a unit. A batch of units is not a transaction: each op commits on its own, and no request-level atomic mode spans units. An atomic= argument belongs to the verbs that define it (bulk create(items=[...]), stream.batch, comm.mark_read) and covers only that op’s own writes, while the cross-op atomic unit, kkernel exec --ops-file --atomic, reads one JSON op per line and accepts no inline batch or chain.
Pass a result to the next operation
Use | when a later operation needs an earlier result. A chain runs in order, and $prev selects data from the operation immediately before it:
request(ops="create(kind=\"entity\", entity_kind=\"concept\", name=\"LoRA\") | link(source_id=$prev.id, target_id=\"<document-id>\", relation=\"introduced_by\")")
$prev refers only to the immediately preceding result. For example, in a() | b() | c(id=$prev.id), the reference in c reads b’s result, not a’s. If a later operation needs a non-adjacent result, split the work into separate request calls or make that result the immediate predecessor.
$prev is available only in function-call chains. JSON form and parallel batches reject it. A failed chain operation prevents subsequent operations from running; completed operations are not rolled back.
Check syntax without executing
Set plan=true on the MCP envelope to parse the same ops text and inspect the loaded verb catalog without dispatching any operation:
request(ops="create(kind=\"note\", content=\"draft\") | get(id=$prev.id)", plan=true)
A successful parse returns parsed: true, mode, stage_count, stages, and units. Each stage reports its index, verb, pack, known, normalized args, unresolved prev_refs, and unit_index. An unknown verb still parses, with known: false and pack: null. References stay as literal strings; paths in prev_refs retain the parser’s representation. units lists each unit’s start/end index range over stages; an ordinary flat batch reports one length-one unit per stage, while a bracketed batch of chains (see “Run parallel units, each its own chain” above) reports one longer range per multi-op unit. Both outcomes include limits with max_ops, max_depth, and max_input_len in bytes. A syntax error returns parsed: false and the ordinary parser error text, with no stages.
Planning accepts ops alone. Supplying presentation, presentation_per_op, format, format_per_op, save_to, or request_id beside plan=true, even as null, produces invalid_params naming the field. A plan does not grant permission, check a lease, or guarantee that a reference will resolve.
The CLI and Python client return the same object:
kkernel exec --plan 'create(kind="note", content="draft") | get(id=$prev.id)'
from khive import Session
plan = Session().plan('create(kind="note", content="draft") | get(id=$prev.id)')
These two clients require an already-running daemon at protocol version 5 with matching configuration. The CLI accepts --db and --config to select that configuration. It exits successfully for either parse outcome; transport or envelope failures exit nonzero. It never starts a daemon or opens a local store for planning. Older daemons refuse the protocol version before dispatch, so a plan cannot silently execute as an ordinary request.
Read the result envelope
By default, request returns a results array and an aggregate summary inline. Each completed operation has one of these shapes:
{ "ok": true, "tool": "search", "result": { "...": "..." } }
{ "ok": false, "tool": "get", "error": "not found: ..." }
For example, a parallel batch can return:
{
"results": [
{ "ok": true, "tool": "stats", "result": { "...": "..." } },
{ "ok": false, "tool": "memory.recall", "error": "..." }
],
"summary": { "total": 2, "succeeded": 1, "failed": 1, "aborted": 0 }
}
A failure in a parallel batch does not stop its siblings. In a chain, entries after the failure are returned as { "ok": false, "tool": "...", "aborted": true }; the summary records their count in aborted. A daemon frame-budget omission (below) is decided after the whole request has already run, so it never triggers this abort — any later chain entry already executed and is reported with its real outcome.
A successful multi-backend search can still be incomplete when one backend is unavailable. In that case the search entry includes "partial": true and a "missing_backends": [...] list plus bounded "backend_errors": {...} causes beside result. Check this operation-level advisory even when ok and the aggregate request status report success. It survives batch/chain execution, presentation modes, and daemon frame-budget handling. If any successful result cannot fit the daemon response frame, that entry becomes an explicit error.kind="response_frame_budget_exceeded" failure with retryable: false — reissuing the identical request overflows the identical budget identically, so this is never advertised as a pace-and-retry condition. The entry instead carries recoverable: for a read-only Assertive verb with no persisted side effect of its own, recoverable: "reduce_result_size" — narrow the verb’s limit or result size and reissue it. Every other verb — including a handful of Assertive verbs that schedule their own persisted write on every dispatch, such as memory.recall’s serve-ledger accounting — already committed its effect (or, for an unregistered verb name, cannot be proven not to have), so the entry instead carries executed: true and recoverable: "read_outcome" — read the outcome back rather than reissue the operation. The batch summary is updated, so a discarded page is never counted as succeeded. A degraded empty result instead carries the same diagnostics inside error.kind="search_incomplete".
The inline results/summary envelope is the default. Set the optional save_to parameter to sink the full results to a JSONL file instead; request then returns a small manifest (the file path, row count, and integrity fields) rather than the inline envelope. Read results and summary only when save_to is omitted. See the API reference for save_to and its export-destination restriction.
Distinguish syntax errors from operation errors
An invalid DSL string never reaches a verb handler. Lexing and parsing failures such as unterminated strings, malformed JSON, too many operations, or invalid use of $prev are reported by ordinary MCP requests as an invalid_params RPC error. Correct the ops string and submit the request again.
Once the DSL parses, validation or execution failures from an individual verb are returned in that operation’s { "ok": false, "error": ... } entry. This is why a parallel batch can partly succeed: each valid, non-conflicting operation has its own outcome.
Gotchas
- Bareword values are not strings. Write
query="LoRA", notquery=LoRA. - Do not mix top-level separators outside any bracket.
verb() | verb(), verb()with no enclosing[...]is invalid. Inside[...], a comma separates units and a|sequences ops within one unit ([verb() | verb(), verb()]is a two-unit batch, not an error); see “Run parallel units, each its own chain” above. Nested JSON arrays and objects may still contain commas. - A request boundary has a cost. Each
requestcall adds a transport and dispatch round trip. The server keeps long-lived state warm between calls, but$prevexists only inside one chain and is not a cross-call cache. Batch independent work and chain direct dependencies when that reduces calls. - Choose the form by dependency. A JSON array is not a sequence. Use a function-call chain when one operation needs another operation’s output.
For verb names, required arguments, and return details, see the API reference.
Raw markdown for this page: /md/request-and-dsl.md