khive exposes exactly one MCP tool, request. Every public verb from the production
packs is dispatched through that single tool via a small request DSL.
This page documents the DSL grammar, the response envelope, and every verb's full
parameter contract, so an agent can call khive correctly without reading Rust source.
The live registry is authoritative: run request(ops="verbs()") against your server to
discover its loaded pack set and total. The static sections below are audited against pack
HandlerDef/ParamDef declarations; the kg catalog was refreshed against the 20-entry
KG_HANDLERS table and its integration contract when db_diagnostics shipped.
An always-machine-readable copy of this page is at
/md/api-reference.md. The site also publishes
/llms.txt (a short index) and /llms-full.txt
(every guide page concatenated) for agents that prefer one fetch over several.
| Pack | Verbs | Load with | Optional? |
|---|---|---|---|
kg |
26 | KHIVE_PACKS=kg |
No — base substrate |
gtd |
7 | KHIVE_PACKS=kg,gtd |
Yes |
memory |
5 | KHIVE_PACKS=kg,memory |
Yes |
brain |
16 | KHIVE_PACKS=kg,brain |
Yes |
comm |
10 | KHIVE_PACKS=kg,comm |
Yes |
schedule |
4 | KHIVE_PACKS=kg,schedule |
Yes |
knowledge |
19 | KHIVE_PACKS=kg,knowledge |
Yes |
session |
7 | KHIVE_PACKS=kg,session |
Yes |
git |
17 | KHIVE_PACKS=kg,git |
Yes |
code |
1 | KHIVE_PACKS=kg,code |
Yes |
workspace |
0 | KHIVE_PACKS=kg,git,gtd,session,workspace |
Yes |
blob |
7 | KHIVE_PACKS=kg,blob |
Yes |
tool |
14 | KHIVE_PACKS=kg,tool |
Yes |
exec |
9 | KHIVE_PACKS=kg,exec |
Yes |
git also registers the commit / issue / pull_request note kinds and the shared
run_ingest core (crates/khive-pack-git/src/ingest.rs) that both git.digest and the
kkernel git-ingest CLI drive. Its seventeen verbs are git.digest (read/ingest), git.ingest_cursor (a read of the
stored ingest cursor and checkpoint for one project and source kind), the four
write verbs git.commit / git.branch / git.update_ref / git.push (ADR-108) that shell to system git
with hardened, allowlisted argv construction, the three read verbs git.status /
git.log / git.init, and the dev-loop verbs git.checkout /
git.diff / git.gates / git.receipts / git.reconcile / git.pr_open / git.pr_review /
git.pr_merge (ADR-182). Initial remote cache setup failures and terminal refetch/reclone
cache failures in git.digest return RemoteFetchError in-process. On the MCP request
wire, the failed tool: "git.digest" result carries
error: {kind: "remote_fetch_error", remote, message, domain_disposition}. remote
is the digest source URL without userinfo, query, or fragment; message names the failed
setup/recovery stage and includes available bounded, sanitized git diagnostics, or the
non-git I/O, size-cap, or ownership-refusal text. It is not a git-write policy-table or
receipt error (ADR-088 Amendment 1, Remote-URL mode, point 5). A successful repair earns
one more snapshot attempt; a snapshot still failing after the bounded repairs remains
InvalidInput. A source that parses as neither a local path nor a remote URL also remains
InvalidInput. An error does not imply rollback of earlier ingest work.
workspace requires kg, git, gtd, and session to be loaded alongside it (the runtime rejects a pack set that omits a declared dependency), so its minimal example lists all four.
schedule requires kg. schedule.remind additionally requires comm.send at
creation time and persists nothing when that delivery capability is absent; the other
three schedule verbs remain available without comm.
code registers the finding note kind and edge rules, plus the code.ingest verb (L1
manifest + L1.5 import-scan source ingest, ADR-085 Amendment 2 — see below); its
findings.json batch ingest still runs only through the kkernel code-ingest admin CLI
path, not the MCP verb surface.
That admin path is history-preserving: a deterministic entity, finding-note,
or annotation-edge ID is skipped even when its row is soft-deleted, so neither
real re-ingest nor --dry-run treats a tombstone as a new record or resurrects
it.
blob registers no note or entity kinds; its seven verbs (blob.put / blob.get /
blob.stat / blob.begin / blob.put_part / blob.commit / blob.abort) expose
content-addressed storage and sequential uploads (ADR-111, ADR-173). A
normal file-backed boot installs a default FsBlobStore rooted beside the database file
even with no [storage.blob] section and no KHIVE_BLOB_ROOT set; the verbs only stay
unconfigured (erroring until a backend is installed) when the server boots against an
in-memory backend, which has no directory to default a root beside. Staged uploads currently
use the filesystem backend; the S3 backend retains blob.put / blob.get / blob.stat
and refuses creation of new staging with Unsupported.
tool (tool.register, tool.ingest, tool.suggest, tool.describe, tool.list, tool.check,
tool.request, tool.grant, tool.deny, tool.revoke, tool.requests, tool.policy, tool.policies)
keeps a namespace-scoped registry of tools, skills, plugins and verbs as kg entities, joins them to
capability concepts with implements edges, and answers what a caller may call (ADR-180).
Pack selection resolves as --pack > KHIVE_PACKS > discovered [runtime].packs > the
built-in production set. With no non-empty selection at any of the first three layers, the
default binary loads all 14 packs. Use verbs() for the current aggregate rather than carrying
a second hand-maintained total here.
Verb names in the kg pack are bare (create, search, link, …). Every other pack
namespaces its verbs with a pack. prefix (gtd.assign, memory.recall,
brain.feedback, comm.send, schedule.remind, knowledge.search, session.store).
The request tool takes one string argument, ops, in one of four forms.
request(ops="search(kind=\"entity\", query=\"LoRA\")")
Up to 100 ops, run with no ordering guarantee between them:
request(ops="[memory.recall(query=\"x\"), memory.remember(content=\"y\")]")
Ops separated by | run sequentially; $prev resolves against the immediately
preceding op's result (not any earlier op — non-adjacent dependencies require splitting
into separate request calls):
request(ops="create(kind=\"concept\", name=\"X\") | link(source_id=$prev.id, target_id=\"<uuid>\", relation=\"extends\")")
$prev path extraction:
| Form | Meaning |
|---|---|
$prev |
the full prior result |
$prev.field |
a nested object field |
$prev.items[0].id |
array index then field |
$prev[2] |
top-level array index |
A quoted string containing $prev is promoted to a substitution automatically
(id="$prev.id" behaves the same as id=$prev.id). To pass the literal four
characters $prev, escape it: "\\$prev".
Equivalent to parallel batch, for callers that prefer to build JSON directly:
request(ops="[{\"tool\":\"search\",\"args\":{\"kind\":\"entity\",\"query\":\"LoRA\"}}]")
JSON form only supports independent ops — a literal $prev anywhere in JSON form is a
parse error (DslError::PrevRefInJsonForm), since JSON form has no chain syntax.
MAX_OPS= 100 per request; exceeding it isDslError::TooManyOps.$previs chain-only. Using it outside a|chain, or anywhere in JSON form, is rejected at parse time.- Write-key conflict detection: a parallel batch where two ops target the same UUID
via
update/delete(id),merge(into_id/from_id), orlink(source_id/target_id) is rejected before any op dispatches, rather than racing. RESERVED_ENVELOPE_ARGS(presentation,presentation_per_op) are envelope-level fields on therequesttool call itself; passing them inside a verb's own argument list is rejected (DslError::ReservedEnvelopeArg).- Mixing
,and|at the top level is rejected (DslError::MixedSeparators). - Only single-level
pack.verbnames are supported —a.b.cisDslError::UnsupportedVerbNesting. - Argument values are JSON literals. Strings must be double-quoted, including inside DSL function-call form — a bare word as a value fails at the assignment, even standalone.
Every op returns its own ok/error outcome; a batch's per-op failure does not abort
its siblings (chain failures do abort the remainder of the chain):
{
"results": [
{ "ok": true, "tool": "search", "result": { "...": "..." } },
{
"ok": false,
"tool": "get",
"domain_disposition": "unknown",
"error": {
"kind": "runtime_error",
"message": "not found: ...",
"domain_disposition": "unknown"
}
}
],
"summary": { "total": 2, "succeeded": 1, "failed": 1, "aborted": 0 }
}aborted counts ops skipped after an earlier failure in a | chain; it is always 0 for
parallel batches, since parallel failures do not cascade.
Every ok: false entry has a required top-level domain_disposition. Inspect it
before retrying: ok: false alone does not mean that a write failed to commit.
domain_disposition |
Meaning and response |
|---|---|
committed |
The domain operation completed before a later audit or response failure. Read error.domain_result for its canonical result; if the result was omitted for a size/depth limit, read the stored outcome back. Do not repeat the write blindly. |
not_committed |
This operation has a proven pre-dispatch refusal or another explicitly proven no-write outcome. Aborted chain operations always have this value. |
unknown |
The operation's effects are not established. Reconcile by a known operation/record identity before deciding whether to retry. |
Non-aborted failures retain an error object, whose domain_disposition matches
the entry-level field. Aborted entries have no error object. A required audit
append failure after a committed write still counts as failed and keeps ok: false;
the new entry-level field makes that committed outcome visible beside ok.
Presentation and daemon frame-budget omission preserve this disposition. See
ADR-133 Amendment 3.
A successful entry can also carry a transport-owned advisories array beside result.
These warnings describe execution context without changing the verb's canonical result or
the batch summary. Presentation and output-format transforms apply only to result, and
frame-budget degradation preserves advisories. For example, inspecting a read-only snapshot
returns normal verb data while making the missing durable dispatch audit explicit:
{
"ok": true,
"tool": "stats",
"result": { "entities": 42 },
"advisories": [
{
"code": "audit_persistence_skipped_read_only",
"severity": "warning",
"component": "audit_event_store",
"reason": "read_only_backend",
"message": "operation completed, but its dispatch audit event was not persisted because the audit backend is read-only"
}
]
}That advisory appears on successful non-help operations only. Failed, aborted, and
help=true entries do not claim that an audit write was skipped.
Base substrate verbs, bare names (no kg. prefix). Category is the illocutionary act
(Searle 1976): Assertive = retrieves state, Commissive = commits a persistent change,
Declaration = changes institutional status by fiat.
Create an entity or note (singleton) or a batch of entities and notes (bulk via items).
Singleton writes preserve the complete source in storage and FTS. If a configured embedder
receives a UTF-8-safe bounded prefix, the successful response includes a warnings array; the
warning is derived from the embedding outcome, not from a separate registry prediction.
A singleton note create may carry key, an immutable namespace/kind identity. An occupied key
refuses with the conflict error key_conflict; existing_id is disclosed under the same rule as
restore_key_conflict below, only when a list check for that namespace/kind/key is allowed.
When the new create's content and properties are exactly equal to the live holder's and
disclosure is allowed, the call instead returns {id, created: false} for the existing note, with
no mutation and no version change: a caller can safely repeat a keyed create without checking
first. An exact replay runs none of the create's own preparation, so it succeeds even when an
annotates target named by the request no longer exists or its embedding model is not registered. A
fresh insert under a supplied key returns the full note object with created: true added,
so the two outcomes are distinguishable without a second round trip. A create without key keeps
its existing response shape and carries no created field.
| Param | Type | Required | Notes |
|---|---|---|---|
key |
string | no | Singleton notes only. At most 512 UTF-8 bytes, no U+0000. See the exact-replay behavior described above. |
kind |
string | conditional | Substrate (entity|note) or granular kind (concept, document, observation, …). Required for the singleton path; not required when items is present. |
name |
string | no | Entity name (singleton). |
entity_kind |
string | no | concept|document|dataset|project|person|org|artifact|service|resource (when kind="entity"). |
note_kind |
string | no | observation|insight|question|decision|reference (when kind="note"). |
content |
string | no | Note body text (singleton notes). |
embedding_content |
string | no | Singleton kind="note" only. A non-empty proper prefix of content sent to the vector embedder instead of the full text, for content that exceeds an embedder's input cap. Stored and FTS-indexed content are always the full content; this only overrides the vector-embedding input. |
description |
string | no | Entity free-text description. |
tags |
array<string> | no | Tag list. |
entity_type |
string | no | First-class type tag, e.g. "paper", "algorithm", "tool". |
properties |
object | no | Arbitrary JSON properties. |
items |
array<object> | no | Bulk creation, entity and note items mixed freely. An entity item is {kind, name, entity_kind?, entity_type?, description?, properties?, tags?} (nonempty name required). A note item is {kind, content, note_kind?, name?, salience?, properties?, tags?}; content is required, and kind is either a granular note kind or "note" (defaults to observation like singleton create). A field that belongs to the other substrate, or any field not listed here, fails that item. edges, annotates, fences, and per-item embedding overrides are refused, not silently ignored. Capped at 1000 items/request. Bulk-created entities and notes both skip embedding until a later reindex. |
atomic |
bool | no | Bulk path. Default true (including when omitted) = every item is validated and prepared before any write, then all items commit in one transaction or none do. false = after whole-envelope validation, each item is parsed, validated and committed independently in index order; one item's failure never blocks a valid sibling. |
verbose |
bool | no | Bulk path. When true, each results[] entry carries the full committed record instead of just id/kind, and entity items are also listed in entities. |
Every bulk response carries a mandatory index-aligned results array, one entry per submitted
item, alongside the existing attempted/created/skipped/failed counters (skipped stays
0, because this surface adds no deduplication). A successful entry is
{"index": 0, "ok": true, "result": {"id": "<uuid>", "kind": "concept", "created": true}}, with
the full record in result when verbose is set. A failed entry is
{"index": 1, "ok": false, "domain_disposition": "not_committed", "error": {...}}; a genuinely
ambiguous storage outcome carries domain_disposition: "unknown" instead. Under atomic: true a
rollback returns no successful item receipts at all: the call itself becomes an error, not a
results array with ok: false entries.
request(ops="create(kind=\"concept\", name=\"RoPE\", description=\"Rotary position embedding\")")
A singleton entity or note create can return post_commit_degradations alongside its
committed id, with entries such as {"stage":"event_append","error":"..."}. This
means the record exists but the later telemetry write failed. Reconcile the returned id;
retrying create would make a duplicate.
Fetch any record by UUID (auto-detects entity/note/edge/event/proposal). Returns the bare record with no envelope: kind is the granular kind (concept, task, observation, ...), entity_type is the governed subtype when one is set, and an entity's vocabulary type lives at properties.type.
When an entity id was consumed by a merge, default get follows merged_into to the
first live kept entity. Its response adds redirected_from, an ordered array of the
merged ids traversed; a live id has no such field. include_deleted=true takes
precedence and returns the requested tombstone with its merged_into pointer.
Cycles and overlong chains fail with redirect cycle detected and
redirect chain too long respectively. The kept id is checked by the Gate before
its entity is returned.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Full UUID or short hex prefix (min 8 chars). |
include_deleted |
bool | no | Return a caller-owned soft-deleted entity, note, or edge without chasing a merge redirect (default false); accepts a full UUID or unique 8+ hex prefix. |
parse_content |
bool | no | Default false. Parse a returned note's content as JSON; invalid JSON refuses with the note id and field. No effect on non-note records. |
request(ops="get(id=\"3f2a9c1e\")")
The returned object has the full substrate shape documented under list below. For an edge,
get additionally returns annotations: Note[]. The array is always present (empty when no live
notes annotate the edge), and each full note object includes annotation_edge_id, the UUID of the
annotates edge connecting that note to the fetched edge. Because get is a by-ID operation,
annotation discovery is namespace-agnostic under ADR-007, matching the fetched edge itself.
Restore a caller-owned soft-deleted entity, note, or edge. Restoring a live record is an
idempotent no-op. A note restore refuses with the conflict error restore_key_conflict when
another live note already holds the same namespace/kind/key identity (details: reason, key,
existing_id); neither record is changed. An entity that was merged into another
entity is a merge tombstone, not a plain soft delete: restore refuses it with merge_tombstone
and names the kept id. A tombstone the caller does not own is answered not found, with or
without a kind hint; the hint is compared only after ownership is established.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Full UUID or unique short hex prefix of the tombstone. |
kind |
string | no | entity|note|edge, or a registered granular kind hint. |
List live records with optional filtering. list does not accept include_deleted and always excludes soft-deleted rows.
For entity entity_type filtering, the non-null column takes precedence. Only
when it is null does list compare a string-valued properties.type, before
pagination. Returned fields and stored rows are unchanged; a matched legacy row
can still return entity_type: null. This fallback is specific to list, not
search.
| Param | Type | Required | Notes |
|---|---|---|---|
kind |
string | yes | entity|note|edge|event|proposal|message, or a granular kind. |
limit |
integer | no | Default 20. |
offset |
integer | no | Default 0; mutually exclusive with after. |
after |
string | no | Exact full entity/note/edge cursor UUID returned as next_after, or "" to begin cursor mode. Prefixes are rejected because keyset pagination needs the stable insertion boundary. |
entity_kind |
string | no | Filter when kind="entity". |
entity_type |
string | no | Filter by type field when kind="entity". |
note_kind |
string | no | Filter when kind="note". |
tags |
array<string> | no | Case-insensitive OR-match over entity tags or note properties.tags; valid for kind="entity" and kind="note". |
source_id / target_id |
uuid | no | Edge endpoint filters, kind="edge" only. |
relations |
array<string> | no | Edge relation filter, kind="edge" only. |
min_weight / max_weight |
number | no | Edge weight bounds, kind="edge" only. |
event_kind / event_kinds |
string / array<string> | no | kind="event" only; additive. |
session_id |
uuid | no | kind="event" only; exact full session UUID. Prefixes are rejected because the filter needs one stable record. |
observed |
array<uuid> | no | kind="event" only; exact full observed-record UUIDs. Prefixes are rejected. |
selected |
array<uuid> | no | kind="event" only; exact full selected-record UUIDs. Prefixes are rejected. |
thread_id |
string | no | kind="message" only; complete UUID or unique 8+ hex prefix resolved across stored thread roots in the caller's effective namespace. Missing or ambiguous prefixes fail explicitly. Legacy exceptions: input that is not hex, or shorter than 8 chars, is matched exactly against stored thread labels (no match → empty list, not an error); for all-hex ≥8-char input, a stored label byte-equal to the input takes precedence over any UUID-prefix match, and a stored label differing only in ASCII case is a final fallback, consulted only when no UUID-prefix candidate resolves. |
direction |
string | no | kind="message" only: inbound|outbound. |
from / to |
string | no | kind="message" only, sender/recipient filter. |
read |
bool | no | kind="message" only. |
delivered |
bool | no | kind="message" only. |
parse_content |
bool | no | Default false. Parse each returned note's content as JSON, including granular note kinds and keyed/cursor pages. Invalid JSON refuses with the note id and field; non-note lists are unchanged. |
request(ops="list(kind=\"entity\", entity_kind=\"concept\", limit=20)")
parse_content=true replaces only a note's returned content field with its JSON value:
objects, arrays, numbers, booleans, strings, and null are supported. Omission or false preserves
the existing string representation, including whitespace and escapes. Parsing is a read projection;
storage, sibling metadata, and pagination envelopes are unchanged. It does not parse note annotations
inside a non-note get response. Content stays opaque to Agent presentation and format reductions,
so IDs, timestamps, nulls, and empty values inside the payload are preserved. format=json returns
that value directly, and format=auto preserves parsed values in its JSON result text.
format=table serializes parsed objects and arrays back to JSON strings for display only;
a parsed content array inside one get record does not itself become a table.
Invalid JSON under parse_content=true returns invalid_input, naming the note UUID and
content field; it does not fall back to a string and is distinct from the existing missing-note
refusal. One invalid returned note refuses its list operation. Both verbs reject
unknown fields by name; older servers without parse_content explicitly reject that parameter.
Offset-mode responses always use {"items": [...], "requested_limit": N, "effective_limit": M, "limit_clamped": bool}. The shape is identical whether or not the
server-side cap binds. Advance offset by items.length, not by either limit field;
effective_limit discloses the server cap but is not a guaranteed row count. The caps are entity
500, note 200, edge 1000, event 1000, and proposal 500. Entity, note, and edge cursor modes return
{"entities": [...], "next_after": ...}, {"notes": [...], "next_after": ...}, or
{"edges": [...], "next_after": ...} and always include the same limit metadata.
Set after="" to begin a stable cursor walk, then pass each non-null next_after value into the
next request with the same filters. The cursor's public value is a UUID; storage resolves it to an
immutable, database-assigned insertion sequence and performs a sequence seek. Every genuinely new id
committed after an issued boundary receives a greater sequence, so equal timestamps, backward clock
movement, and lower UUIDs cannot make it fall behind that boundary.
Cursor mode is a live walk, not an MVCC snapshot. Inserts committed before a later page query may
extend the walk. After a substrate/namespace query returns next_after: null, rows committed after
that terminal query require a new walk from after="". Updates and deletes can change whether an
unvisited row matches the filters. A cursor that was hard-deleted, is outside the caller's visible
namespaces, or otherwise cannot be resolved returns an error instead of silently restarting. Cursor
mode and offset are mutually exclusive. Filtered note cursor walks may additionally return
scan_incomplete: true with the last safe continuation cursor when their 10,000-row safety
ceiling is reached before another matching note is proven.
Outcome-filtered event offset pages may also set scan_incomplete: true when their bounded
post-filter scan cannot prove exhaustion. Such a short page is not terminal: advance only by the
rows actually returned, and treat an incomplete empty page as non-resumable without a narrower
filter or a larger effective limit.
Row shape (each item in the offset or cursor envelope) depends on kind.
For kind="entity", "note", "edge", and "event", the row is the full public record shape
for that substrate, listed below in its verbose form (the shape returned with
presentation="verbose", which is also the default for kkernel exec and the khive CLI).
This is the key difference from search and neighbors below, which both return narrow
projections regardless of presentation mode.
Every MCP call that omits presentation gets Agent mode instead (list is not on the
AlwaysVerbose verb list in crates/khive-types/src/pack.rs), which conditionally reshapes the
rows below (crates/khive-runtime/src/presentation.rs): a null field is dropped entirely rather
than returned as null, unless its name is on the lifecycle-preserve list (deleted_at among
others, but not merged_into/merge_event_id/expires_at); empty strings, arrays, and objects
are dropped; id/source_id/target_id/merge_event_id and other _id-suffixed fields are
shortened to an 8-character prefix; created_at/updated_at/deleted_at are
compacted to a relative or minute-truncated form; and salience/decay_factor are truncated to
3 significant figures. Pass presentation="verbose" to get the exact shapes below unconditionally.
kind="entity":{id, namespace, kind, entity_type, name, description, properties, tags, created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref}.content_ref, when present, is the compatibility projection of attachment rolecontent; entities no longer store a writable same-named column.created_at/updated_at/deleted_atare ISO-8601 strings (the store keeps them as epoch-microseconds internally; the handler converts before returning).kind="note":{id, namespace, kind, status, name, content, salience, decay_factor, expires_at, properties, created_at, updated_at, deleted_at}. Notes have no top-leveltagsfield: unlike entities, tags live insideproperties.tags. If the note'sproperties.statusis set (e.g. agtdtask's lifecycle status, or acommmessage's delivery state), the row's substrate-levelstatus(normally"active") is renamed tolifecycle, and the top-levelstatusis replaced with theproperties.statusvalue, so agtd/commconsumer reads the pack-level status directly off the row instead of digging intoproperties. When noproperties.statusis set,statusstays the raw substrate value and there is nolifecyclekey.kind="edge":{id, namespace, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, metadata, target_backend}.kind="event":{id, namespace, verb, substrate, actor, kind, outcome, payload, payload_schema_version, profile_state_version, duration_us, target_id, session_id, aggregate_kind, aggregate_id, created_at}.kind="proposal"is a supportedlistkind but is not a full stored record: it returns a purpose-built projection,{id, proposer, title, status, created_at, updated_at, expiry, last_decision, review_count, approve_count, reject_count}(built incrates/khive-pack-kg/src/handlers/proposal.rs). That field set is thepresentation="verbose"projection; the default Agent mode applies the same generic reshaping as the otherlistrows: non-lifecycle null/empty fields are omitted (a nullexpiry, an emptylast_decision), ids are shortened, and timestamps are compacted.
None of these match search's {id, entity_kind|note_kind, score, title, snippet} rows or
neighbors's flat {origin_id, id, edge_id, relation, weight, name?, kind?, entity_type?}
rows. search and neighbors are built for ranking and graph-walking, not display: fetch the
full record with get(id=...) (or list) when you need more than what they return.
Return aggregate KG substrate counts (entities, edges, notes). No params.
The response carries a count_scope object stating what the counts range over:
{"namespaces": "caller_visible", "rows": "live_only"} — counts cover the namespaces visible to
the caller and exclude soft-deleted rows.
request(ops="stats()")
Patch entity, note, or edge fields. Field set depends on substrate: entities accept
name/description/properties/tags; notes accept
name/content/salience/decay_factor/properties/tags; edges accept
relation/weight/properties.
Entity/note text updates use the same full-source storage and bounded embedding contract as
singleton create; a successful response includes warnings when embedding actually truncated.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Record to patch. |
kind |
string | no | Substrate hint (entity|note|edge); omit to resolve from the UUID. |
name |
string | no | Entities and notes. |
description |
string | no | Entities only. |
content |
string | no | Notes only (body text). |
salience |
number | no | Notes only, 0.0–1.0. |
decay_factor |
number | no | Notes only, >= 0. |
relation |
string | no | Edges only, one of the 19 canonical relations. |
weight |
number | no | Edges only, 0.0–1.0. |
properties |
object | no | Shallow-merged in. |
tags |
array<string> | no | Entities and notes: replaces the tag list; omission preserves it, [] clears it. |
Note tags remain stored and returned in properties.tags. Updating that property directly
also replaces the list. When a request supplies both tags and properties.tags, top-level
tags wins, including tags=[]; tags are never unioned. Other properties are shallow-merged
as usual.
An unfenced note update whose normalized patch equals the stored value is a no-op: the stored row
comes back with unchanged: true, and version and updated_at do not move. A note update that
names expected_version is always a write when it is accepted, identical patch included: the
version advances by exactly one and unchanged is never set, because the version a fenced write
mints is the only thing a rival writer can fail against.
request(ops="update(id=\"<uuid>\", salience=0.7)")
Soft or hard delete a record.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Record to delete. |
kind |
string | no | Substrate hint; omit to resolve from the UUID. |
hard |
bool | no | Default false (soft delete). True permanently removes with edge cascade. |
request(ops="delete(id=\"<uuid>\")")
Entity and note deletes return deleted: true and the id after the row change commits,
even if a later index cleanup or event append fails. In that case the response also
contains post_commit_degradations entries with stage (fts_cleanup,
vector_cleanup, or event_append) and error. A failed index cleanup can leave a
stale search entry; use the returned id to reconcile it instead of repeating the delete.
Deduplicate two entities or notes. Returns {kept_id, removed_id, edges_rewired, edges_contract_skipped, edge_conflict_preimages, properties_merged, tags_unioned, content_appended, dry_run} — chain with $prev.kept_id, not
$prev.id (merge has no top-level id field). When rewiring collides with an
existing edge natural key, edge_conflict_preimages records the surviving edge
id, the complete dropped edge, and any incident annotation edges removed by the
hard-delete cascade. The same preimages are stored in the merge audit event.
When the surviving entity or note is reindexed and an embedder bounds its input, the successful
response also includes the standard embedding-truncation warnings advisory.
Entity merges pass a cheap safety floor first: the two records must share an
entity kind, carry similar names, and not be owned by disjoint projects. A merge
that fails it is refused with a conflict error naming the check in
details.guard and what the check compared in details.compared. Under
dry_run=true that refusal is returned as the plan instead of as an error —
{dry_run: true, would_merge: false, refused_by, compared, into_value, from_value, detail} — so a dry run predicts the floor rather than failing on
it. Every dry_run response carries would_merge.
| Param | Type | Required | Notes |
|---|---|---|---|
into_id |
uuid | yes | Entity that survives the merge (canonical). |
from_id |
uuid | yes | Entity merged from; soft-deleted afterward. |
dry_run |
bool | no | Return the plan without mutating or emitting. |
force |
bool | no | Skip the entity safety floor; the caller takes responsibility for the merge. |
request(ops="merge(into_id=\"<canonical-uuid>\", from_id=\"<dup-uuid>\")")
Hybrid FTS + vector search with RRF fusion.
| Param | Type | Required | Notes |
|---|---|---|---|
kind |
string | yes | Substrate or granular kind to search. |
query |
string | yes | Free-text query. |
text_mode |
string | no | KG entity/note lexical matching: all_terms (default, including null) or any_term; does not change the vector arm. |
limit |
integer | no | Default 10. |
entity_kind |
string | no | Entity-substrate searches only. |
entity_type |
string | no | Entity-substrate searches only. |
note_kind |
string | no | Note-substrate searches only. |
include_superseded |
bool | no | Note-substrate searches only; default false excludes notes targeted by a supersedes edge. |
properties |
object | no | Match records whose properties contain all listed key=value pairs, applied before result truncation inside a bounded candidate window. |
tags |
array | no | OR-match against tags; entity tags matched at the SQL level, note tags read from properties.tags. |
min_rank_score |
number | no | Inclusive deterministic rank floor in [0,1], default 0; applied after fusion and modifiers, before the final limit. Strategy/query-local, not calibrated relevance. |
min_score |
number | no | Deprecated exact alias of min_rank_score in v0.8. Supplying both names is invalid, even when equal. |
order_by |
string | no | score (default), created_at, or updated_at; timestamps sort newest first after the rank floor, within the bounded candidate window. |
request(ops="search(kind=\"entity\", query=\"knowledge graph runtime\", limit=10)")
entity_kind and note_kind are compatibility filters for the corresponding
substrate-level kind. A granular discriminator such as kind="concept" or
kind="observation" may be paired with the same compatibility value, but a
contradiction is rejected. Entity-only fields on a note search and note-only
fields on an entity search are also rejected explicitly; they are never
ignored. properties must be an object and tags must be an array of strings.
The same validated request is used for single- and multi-backend execution.
In multi-backend mode a backend failure with surviving hits yields those hits
with status: "partial", deprecated partial: true, missing_backends, and a
backend_errors object mapping each retained failed backend to its bounded,
credential-masked backend id and cause. Masked backend ids use a stable hash
suffix so distinct failed legs remain distinguishable. These fields sit beside result and survive
presentation and response-frame compaction. If no hit survives filtering, the
operation is ok: false with error.kind="search_incomplete"; the structured
error carries the same diagnostics. backend_errors_truncated plus
backend_errors_omitted explicitly report causes omitted by safety bounds.
The masking uses ADR-115 Amendment 2's permanent McpDiagnostic surface. Diagnostics are response
data rather than durable records and can neither consume a manifest exemption nor produce a secret
gate stamp or exemption-success event.
When every failed leg is structurally classified as timeout, the error has
retryable: true and a positive retry_after_ms; any non-timeout leg keeps
retryable: false and omits retry_after_ms. Classification and pacing use the
full pre-truncation failure set. The server pace is 2,000ms plus 250ms for each
failed backend after the first, capped at 10,000ms.
Clients that act on retryable: true are expected to use at most three total
attempts per logical request. Before retry n (starting at 1), wait
max(retry_after_ms, 2000) * 2^(n - 1) milliseconds plus nonnegative random
jitter no greater than half that base; never retry sooner than the server's
value. A breaker keyed by the failed backend set opens after three consecutive
all-timeout outcomes, suppresses both first attempts and retries for 30 seconds,
then admits one half-open probe. Success closes the breaker; another
all-timeout outcome reopens it for 30 seconds. A client that does not implement
the complete attempt budget, backoff, and breaker policy must not retry this
error automatically.
Every successful KG search also carries arm_participation beside result;
search_incomplete carries the same object inside error:
{
"arm_participation": {
"text": {
"status": "ran",
"candidate_count": 0,
"mode": "all_terms",
"reason": "No text candidate survived matching, filtering, fusion, and the result limit. Plain text search combines normalized term groups conjunctively; try fewer terms."
},
"vector": { "status": "ran", "candidate_count": 8 }
}
}Each arm status is ran, skipped, or error. The text arm always reports
its effective mode; the vector arm has no mode. Text ran with zero final
candidates carries the exact all-terms reason shown above, or the shorter
No text candidate survived matching, filtering, fusion, and the result limit.
for any_term. Other text statuses and positive counts omit reason.
An arm with status: "ran" and zero candidates completed but contributed no
final hit; skipped means it was
not selected (for example, vector search without a configured embedding model);
and error means that arm itself failed on at least one backend where it was
selected — including a backend whose other arm completed normally. A backend
whose text leg completes and whose vector leg alone fails is therefore not
"missing": it still contributed usable text hits, so it does not appear in
missing_backends or backend_errors, and the response keeps
status: "complete". arm_participation.vector.status is the only place that
failure surfaces on such a response — text still reports "ran". A response
is "partial" (with missing_backends/backend_errors) only when a whole
backend's search failed outright — auth, timeout, or a failed text leg — not
merely one of its arms. On such degraded responses, the bounded reason stays in
backend_errors; its kind is the single constant backend_error in v0.8.0,
and the two-value timeout | backend_error vocabulary of ADR-130 Amendment 2
ships in v0.9.0. Arm entries do not duplicate those messages. candidate_count
counts final hits
whose source includes the arm, after server filters and the result limit, so a
both hit increments both counts and each count is bounded by limit.
This per-arm tolerance applies to the coordinated multi-backend search path,
for both entity and note substrates. A single-backend deployment (the
default — no coordinator installed) has no per-backend fan-out to isolate a
failing arm from: a vector-arm failure there fails the whole search call, and
the response carries no arm_participation at all.
For an entity-name presence check, issue the short bare canonical name and
require the matching row itself to report source: "text" or "both". An
all-vector response, or text-arm status error/skipped, is not evidence that
the name is absent. This check uses the default all_terms mode; an any_term
hit matching only one token does not establish the full canonical name. Long
keyword-dense queries may legitimately report text
ran with candidate_count: 0 because the lexical expression is selective;
the explicit arm evidence makes that different from a silent skip or failure.
Response shape (kind="entity" rows, presentation="verbose"):
[
{
"id": "3f2a9c1e-...",
"entity_kind": "concept",
"rank_score": 0.0909,
"rank_score_kind": "rrf",
"signals": { "keyword_score": 12.5 },
"score": 0.0909,
"title": "LoRA",
"snippet": "matched text from the description/properties"
}
]kind="note" rows are identical except the kind field is named note_kind instead of
entity_kind. That kind field is present on every row in the verbose shape above but is null
in the rare case where the record was deleted between the search hit and the metadata lookup
that fills it in; title/snippet are null for the same reason, or when the underlying
FTS/vector hit carried no snippet text. search is not on the AlwaysVerbose verb list
(crates/khive-types/src/pack.rs), so a call that omits presentation gets Agent mode instead:
entity_kind/note_kind/title/snippet are omitted from the row entirely when null rather
than returned as null (they are not on the lifecycle-preserve list), and id is shortened to
an 8-character prefix (crates/khive-runtime/src/presentation.rs).
rank_score is the deterministic ordering value under the strategy named by
rank_score_kind: rrf, vector, keyword, weighted, or union. It is not
a probability, a percentage match, or comparable across queries. In v0.8,
deprecated score equals rank_score exactly after conversion to JSON. The
min_rank_score range is an input constraint, not a calibration claim.
signals carries available pre-fusion vector_similarity and/or keyword_score.
Absent evidence keys are omitted, never synthesized as zero. Component scores
are scoped to the producing backend and model; vector similarities from different
embedding models are not comparable. Canonical evidence-free hits carry {};
Agent presentation drops that empty object and rounds ranking and signal values
to three significant figures after all ranking and filtering decisions.
The local RRF construction differs by substrate:
- Entity (
crates/khive-runtime/src/retrieval.rs): each retrieval leg (lexical, vector) that returns the entity contributes1 / (k + rank)withk = 10; contributions from every leg that hit the entity are summed, then a flat+0.5boost is added when the entity's title is an exact case-insensitive match for the query. A single-leg rank-1 hit is0.0909; an entity hit by both legs at rank 1, with an exact title match, would score1/11 + 1/11 + 0.5 ≈ 0.682. - Note (
crates/khive-runtime/src/operations.rs): the same per-leg RRF sum, but withk = 60, is then multiplied by a salience-derived weight,0.5 + 0.5 * salience(salience defaults to0.5when unset), so the fused rank score is scaled down for low-salience notes and left closer to unscaled for high-salience ones.
Both local modifiers retain kind rrf and leave component signals unchanged.
A coordinator with one selected backend preserves that backend's hit. With
multiple selected backends, it sums deterministic outer RRF contributions and
publishes kind rrf. For a repeated ID, the complete signal set comes from the
hit with the best within-backend rank, ties following deterministic backend
order; it is never combined across backends.
This row shape never includes the full entity/note record (no description, content,
properties, or tags) in either presentation mode, only enough to rank and
identify the hit. It diverges from both neighbors and list's row shapes above; see list's
"Row shape" note above for the full comparison.
Create or replace a typed directed edge. The natural key is
(namespace, source_id, target_id, relation) after symmetric endpoint
canonicalization. A live match retains its row ID and creation time while
replacing weight and metadata. A soft-deleted match is refused unless the
caller explicitly opts into restoration.
| Param | Type | Required | Notes |
|---|---|---|---|
source_id |
uuid | yes | Source node. |
target_id |
uuid | yes | Target node. |
relation |
string | yes | One of the 19 canonical relations: contains|part_of|instance_of|links_to|located_in|extends|variant_of|introduced_by|supersedes|derived_from|precedes|depends_on|enables|implements|competes_with|composed_with|annotates|supports|refutes. |
weight |
number | no | Default 1.0. 1.0=definitional, 0.7-0.9=strong, 0.4-0.6=plausible. |
metadata |
object | no | Edge metadata. On a live natural-key match this replaces the prior metadata object; it is not merged. |
resurrect |
bool | no | Default false. Set true to restore a soft-deleted natural-key edge; omission never clears deleted_at. |
request(ops="link(source_id=\"<uuid-a>\", target_id=\"<uuid-b>\", relation=\"extends\")")
The singleton response contains the persisted edge plus mutation, one of
created, updated, or resurrected. Bulk summaries report those three
counts separately; verbose bulk rows carry the same per-edge field. Every
successful mutation emits LinkCreated (create) or EdgeUpdated
(replacement/restoration), with the previous edge snapshot for non-create
mutations. list(kind="event", observed=["<edge-uuid>"]) therefore retrieves
the edge's mutation history.
Immediate graph neighbors. Without an explicit limit the response is the bare array of hits it has always been. An explicit limit returns a page instead: {"neighbors": [...], "next_after": <cursor or null>, "requested_limit": N, "effective_limit": M, "limit_clamped": bool}, so a dense node can be walked in bounded steps. Each record hit carries origin_id for the queried node, edge_id, relation, weight, and the neighbor's id, kind and name; include_entity_type=true adds entity_type when the neighbor has one.
Each returned hit includes origin_id, the resolved queried node. This lets
batch callers verify that every result is associated with the submitted root.
| Param | Type | Required | Notes |
|---|---|---|---|
node_id |
uuid | yes | Node whose neighbors to return. |
direction |
string | no | outgoing|incoming|both (default both). |
relations |
array<string> | no | Restrict to these relation types. |
min_weight |
number | no | Exclude edges below this weight. |
limit |
integer | no | Page size, capped at 1000; explicit values report requested_limit, effective_limit, limit_clamped and switch the response to the page shape. Default: every neighbor, as a bare array. |
after |
string | no | Opaque cursor from a previous page's next_after; "" starts a cursor walk. Requires an explicit limit. Order is weight descending, then neighbor id, then edge id. |
neighbor_kinds |
array<string> | no | Keep only neighbors of these entity or note kinds, applied before the limit. |
projection |
string | no | edge (edge identity and endpoints only), summary (neighbor identity and metadata), or record (full hit, default). |
request(ops="neighbors(node_id=\"<uuid>\", direction=\"both\")")
Response shape:
[
{
"origin_id": "<the queried node_id>",
"id": "<neighbor node id>",
"edge_id": "<uuid>",
"relation": "extends",
"weight": 0.9,
"name": "LoRA",
"kind": "concept",
"entity_type": "paper"
}
]Flat rows, one per edge: never the neighbor's full entity/note record. name, kind, and
entity_type are filled in by a batch entity+note lookup performed after the graph query, and
are omitted from the JSON entirely (not null) when that lookup can't resolve the neighbor
id (a dangling/bogus id that never matched an entity or note row). Soft-deleted entity neighbors
are a separate case: the runtime filters them out before the response is built
(crates/khive-runtime/src/operations.rs, neighbors_with_query), so a soft-deleted neighbor
produces no row at all rather than a row with omitted fields. neighbors is AlwaysVerbose
(crates/khive-types/src/pack.rs), so this omission behavior is unconditional regardless of
presentation; search's entity_kind/note_kind follows the opposite rule in verbose mode
(always present, only ever null), but is itself omitted-when-null under the default Agent mode.
entity_type is included only when include_entity_type=true was passed, and is never set for
a note neighbor (notes have no entity_type).
kind is overloaded: for an entity neighbor it is the entity's base kind (e.g. concept); for
a note neighbor it is the note's kind (e.g. observation); annotation edges routinely link an
entity to a note, so a neighbors result set can mix both. There is no separate field stating
which substrate a given neighbor belongs to; disambiguate by checking kind against the closed
entity-kind vocabulary (§"The 9 entity kinds" in AGENTS.md) vs. the note-kind vocabulary
(§"The 5 note kinds"), or call get(id=...) on the neighbor's id.
Bounded multi-hop BFS traversal. Nodes are selected at their shallowest depth; same-depth tie order is intentionally unspecified. Limits count non-root first-visit nodes independently per distinct root.
| Param | Type | Required | Notes |
|---|---|---|---|
roots |
array<uuid> | yes | Starting UUIDs; maximum 100 raw entries, then de-duplicated after resolve. |
max_depth |
integer | no | Default 3; maximum 10; values above 10 are rejected. |
direction |
string | no | out/outgoing, in/incoming, or both (default both). |
relations |
array<string> | no | Restrict traversal to these relations. |
min_weight |
number | no | Minimum edge weight, finite and within 0.0–1.0. |
limit |
integer | no | Non-root results per root; default 100, maximum 1,000. |
include_roots |
boolean | no | Include depth-0 roots (default true; they do not consume limit). |
include_properties |
boolean | no | Include entity properties on path nodes (default false). |
One public request shares a 100,000-adjacency-row work budget and five-second storage-expansion deadline across all roots and visible namespaces. Self-loops, parallel paths, and rows rejected by first-visit de-duplication still consume work. Exceeding a shape bound, work budget, or deadline returns an error and never partial paths. Traversal reads use statement-scoped snapshots, so concurrent writes may become visible between frontier expansions; a single old WAL snapshot is never held for the full operation.
request(ops="traverse(roots=[\"<uuid>\"], max_depth=2)")
The response contains exactly one traversal object per distinct requested root. Each path node
has id, via_edge, and depth; resolvable entity and note nodes also carry name and kind.
Note enrichment matches neighbors, including its [kind] display-name fallback for a nameless
note reached through an annotation edge. properties remains entity-only and is included only
when include_properties=true.
Entity-anchored graph context in one call (ADR-089).
Resolves anchors from query and/or entity_ids, expands 1-2 hops via the same
runtime op behind neighbors, and assembles a budgeted, deterministically-ordered
response — replacing a caller-side search | neighbors chain with a single
round-trip. direction defaults to "both", matching neighbors and traverse
(outgoing/incoming on request). At least one of query/entity_ids is required.
One embedding inference when query is used; zero for a pure entity_ids call.
| Param | Type | Required | Notes |
|---|---|---|---|
query |
string | no* | Semantic anchor selection via hybrid search; adds anchors after entity_ids. |
entity_ids |
array<string> | no* | Explicit anchor UUIDs/prefixes/slugs. Honored in full, never clamped by limit. |
hops |
integer | no | Expansion depth, clamped 0..=2 (default 1). |
budget |
integer | no | Output budget in Unicode scalars of compact JSON, clamped 256..=65536 (default 4096). |
relations |
array<string> | no | Edge-relation filter applied during expansion. |
direction |
string | no | outgoing|incoming|both (default both). |
limit |
integer | no | Max anchors from the query leg, clamped 1..=20 (default 5). |
fanout |
integer | no | Max neighbors per expanded node per hop, clamped 1..=50 (default 10). |
* at least one of query/entity_ids required.
Every number the caller supplies that the verb clamps is reported back beside the body, under its
own name: requested_hops / effective_hops / hops_clamped, and likewise for budget, limit
and fanout. A raise to a minimum counts as a clamp (budget=1 reports effective_budget: 256,
budget_clamped: true). A number the caller did not supply is not reported; the default is not a
clamp.
request(ops="context(query=\"rotary position embedding\", hops=1, budget=4096)")
Response shape:
{
"anchors": [
{
"entity": { "id": "…", "name": "…", "kind": "concept", "description": "…", "properties": {} },
"neighbors": [
{
"id": "…",
"name": "…",
"relation": "extends",
"direction": "outgoing",
"weight": 0.9,
"hop": 1,
"via": null,
"description": "…"
}
]
}
],
"truncated": false,
"dropped": { "anchors": 0, "neighbors": 0 }
}GQL or SPARQL pattern matching (read-only). Write-shaped input (SPARQL
INSERT/DELETE/LOAD/WITH…DELETE, GQL/Cypher CREATE/DELETE/DETACH DELETE/SET/MERGE) is
rejected — use create/update/link/merge/delete to mutate the graph. Queries
that mix fixed-length and variable-length chains are not compiled in one call; split
them into separate query() calls. GQL string equality uses SQLite COLLATE NOCASE,
so WHERE e.name = "LoRA" matches both LoRA and ASCII case variants such as lora.
GQL results have a deterministic identity order. When a response has has_more: true,
repeat the same query with SKIP set to its next_offset; keep page_size unchanged.
SPARQL OFFSET is not part of the supported dialect.
| Param | Type | Required | Notes |
|---|---|---|---|
query |
string | yes | GQL or SPARQL pattern string, read-only. GQL supports SKIP n [LIMIT m]. |
page_size |
integer | no | Rows per call; minimum 1, default 500, clamped to hard cap 10,000. |
limit |
integer | no | Deprecated alias for page_size; supplying both is an error. |
request(ops="query(query=\"MATCH (c:concept)-[:extends]->(d:concept) RETURN c, d LIMIT 20\")")
For an exhaustive audit, omit query-text LIMIT so the server can report whether another
page exists. An explicit LIMIT at or below page_size is a terminal caller-chosen bound.
request(ops="query(query=\"MATCH (a)-[r:depends_on]->(b) RETURN a, r, b\", page_size=500)")
# {"rows":[...],"offset":0,"page_size":500,"has_more":true,
# "next_offset":500,"truncated":true,...}
request(ops="query(query=\"MATCH (a)-[r:depends_on]->(b) RETURN a, r, b SKIP 500\", page_size=500)")
next_offset is offset + rows.length and appears only on GQL pages with has_more: true.
truncated remains as a compatibility alias for has_more. Offset paging is stable while
the matched graph is unchanged; concurrent inserts or deletes can shift later pages.
Create an event-sourced change proposal. Returns {id, full_id, parent_id, status, proposer, title}.
full_id and a non-null parent_id remain canonical 36-character UUIDs in Agent mode so
ancestry can be submitted again unchanged; id may be the ordinary 8-character Agent form.
Reuse the returned full_id as parent_id in a subsequent proposal request. The changeset
field has nested objects and cannot be expressed in function-call DSL form; use JSON form (whose
operations do not support $prev).
| Param | Type | Required | Notes |
|---|---|---|---|
title |
string | yes | Non-empty short title. |
description |
string | yes | Non-empty full description. |
changeset |
object | yes | Discriminated by kind: add_entity, update_entity, add_edge, add_note, merge_entities, supersede_entity, compound (nested steps). Every nested identifier is a full UUID; prefixes are rejected because resolution could miss, be ambiguous, or change stable proposal intent. |
reviewers |
array<string> | no | Actor IDs requested as reviewers. |
expiry |
integer | no | Expiry timestamp, microseconds since epoch. |
parent_id |
uuid | no | Full UUID of the parent proposal. Prefixes are rejected because ancestry is an explicit stable reference; responses preserve it canonically. |
request(ops="[{\"tool\":\"propose\",\"args\":{\"title\":\"Add GQE\",\"description\":\"Register the GQE concept\",\"changeset\":{\"kind\":\"add_entity\",\"entity\":{\"kind\":\"concept\",\"name\":\"GQE\"}}}}]")
Approve, reject, comment, or request changes on a proposal.
When approval immediately applies an embedding-bearing changeset, the response includes the
standard warnings advisory if that committed apply bounded any embedding input.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Full UUID or 8-char short ID of the proposal. |
decision |
string | yes | approve|reject|comment|request_changes. |
comment |
string | no | Reviewer comment. |
request(ops="review(id=\"<proposal-id>\", decision=\"approve\")")
Withdraw an open proposal (proposer-only).
| Param | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Full UUID or 8-char short ID of the open proposal. |
rationale |
string | no | Reason for withdrawing. |
request(ops="withdraw(id=\"<proposal-id>\")")
Resolve natural-language references to ids. Each ref in refs is resolved through:
(1) id-string passthrough (UUID or 8+ hex prefix) via the existing by-ID path; (2) this
actor's recently-referenced ring; (3) a case-sensitive exact match on entities.name;
(4) hybrid search over the namespace. Returns one of
Resolved{id,confidence} | Ambiguous{candidates} | NotFound per ref — never a
silent pick among close candidates. Read-only: performs no mutation.
A resolved id consumed by an entity merge follows the transitive merged_into
chain. Its result contains the live kept id and redirected_from: [old_id, ...];
an unredirected result has no marker. resolve has no include_deleted option.
Cycles and overlong chains fail with redirect cycle detected and
redirect chain too long. The kept id is checked by the Gate before return.
| Param | Type | Required | Notes |
|---|---|---|---|
refs |
array<string> | yes | Natural-language references to resolve (UUID, hex prefix, exact entity name, or free text). |
kind |
string | no | Restricts the exact-name and hybrid-search stages to an entity kind. No effect on the id-string or ring stages. |
limit |
integer | no | Max candidates returned per ref from the stage 4 hybrid-search fallback. Default 5, max 20. |
request(ops="resolve(refs=[\"the old record\", \"<uuid>\"])")
Report the caller identity and namespace scope the runtime already resolved for this request,
plus the serving process's build identity. It takes no parameters and never returns tokens or
credentials: {actor_id, actor_kind, unattributed, namespace, visible_namespaces, build: {version, revision}}.
build.version is the package version; build.revision is the source revision shown by that
process's kkernel --version, including any -dirty suffix or the explicit unstamped fallback.
These compile-time values share the source used by db_diagnostics().build; diagnostics calls
the optional revision field build_hash and reports it as null for an unstamped build.
Use whoami() for a lightweight build-identity probe: its handler reads existing token state
and immutable build metadata without invoking database diagnostics or a checkpoint probe.
request(ops="whoami()")
Report whether the secret gate would refuse a note body, without writing anything. Takes
content (required) plus optional name and properties, and runs the same checks in the same
order a note write runs before it stores: content, then name, then every string leaf of
properties. Returns {would_refuse, detector, trigger, masked, location, message, masked_preview: {content, name}}. On a refusal message is the exact text the write would
have failed with, location names the field (note.content, note.name, note.properties)
and masked is the candidate as first6...N; on acceptance those fields are null.
masked_preview is the input through the canonical masker either way. Nothing is stored and
no event is emitted, so the verb is safe to call on a body you do not intend to keep.
request(ops="scan(content=\"api_key=sk-...\")")
Report reader/writer contention, graph-edge integrity, and WAL/checkpoint diagnostics for
all database files already opened by this server. The existing top-level fields still describe
main: build and process identity, checkpoint counters, a PASSIVE checkpoint probe, the -wal
sidecar file size, page-level database size composition, and a WAL-pin holder census.
Takes no parameters.
databases is an array with one entry per canonical database file (or distinct in-memory
pool), main first. Each entry has
backend_names (all configured names that share the file), canonical path, diagnostics
(the same field set as the existing top-level report), and error. Successful entries have
error: null; a failed inspection has diagnostics: null and an error string while other files
still report. If main itself fails, its error remains in the first entry and the root contains
only databases because there is no valid primary report to flatten. The list uses only pools
opened at startup; it does not open an unserved path or create a missing database file.
The top-level process-lifetime note_search_ann_route_total and
note_search_fallback_route_total counters distinguish warm-graph searches from
the exact sqlite-vec fallback. Unlike pool-scoped contention counters, these
totals do not reset when a database pool is reopened.
Every successful per-file report includes process alongside build:
{
"process": {
"pid": 12345,
"started_at": 1789272000,
"started_at_unavailable_reason": null,
"pool_generation": 1
}
}pid identifies the OS process serving this request. started_at is its OS-reported creation
time in whole Unix epoch seconds (UTC), including when the pool or the first diagnostics request
was created later. If that lookup is unsupported or unavailable, started_at is null and
started_at_unavailable_reason is nonempty and names the platform limitation; a missing or empty
reason with a null start time is a producer defect. Request time and zero are never used as
fallback timestamps. Compare PID and available start time together when distinguishing process
restarts: PIDs can be reused, and two processes can start within the same second.
pool_generation starts at 1 in each process and increments whenever the main pool is
reconstructed. Additional handles to the same pool and secondary-pool construction do not
advance it. The root reader and writer counters belong to main; compare
(pid, started_at, pool_generation) to identify its counter window. Within databases, reader
and writer counters belong to the entry's pool. A secondary pool can be reconstructed without
advancing the main pool_generation; its canonical path and process identity also stay the
same. The report has no per-secondary generation field, so it cannot identify that
reconstruction directly. A decrease in a cumulative secondary-pool counter indicates a new
observation window. Checkpoint counters and audit failure
counters remain process-global. Point-in-time gauges and
consecutive-failure counts can also decrease during normal operation.
reader_contention is scoped to the main ConnectionPool and resets only when that pool is
reconstructed. reader_admission_capacity and available_reader_admission_slots are the
configured total budget and its point-in-time availability; pooled reads and the explicit
raw-SQL deferred-transaction exception share it. reader_acquisitions is the sum of
pooled_reader_checkouts and request-path standalone_reader_opens, while
infrastructure_standalone_reader_opens is deliberately separate. Ordinary file-backed reads
must leave standalone_reader_opens flat. reader_checkout_timeouts counts admission waits that
exhausted KHIVE_CHECKOUT_TIMEOUT_SECS before work began, not cooperative request cancellation.
active_pooled_reader_checkouts, peak_active_pooled_reader_checkouts,
completed_pooled_reader_checkouts, and max_completed_reader_hold_micros expose concurrency
and lifecycle evidence; completed hold includes connection reset/replacement before reuse.
reader_replacement_open_failures counts a disqualified pooled-reader return whose replacement
connection then also failed to open, permanently shrinking the physical pool by one slot below
max_readers; non-zero here means the pool has fewer physical reader connections than
configured, and each occurrence is also logged at warn.
The timeout setting applies to each admission attempt. A verb that issues several sequential reads can spend more than one configured timeout in total wall time, but each attempt is bounded and saturation never falls back to opening a standalone connection.
writer_contention contains counters captured once per request:
writer_acquisitions is the total of pooled_writer_acquisitions,
standalone_writer_acquisitions, and writer_task_acquisitions. The first counts successful
finite-wait main-pool mutex checkouts, the second counts successful per-operation file-backed
standalone writer opens, and the third counts dequeued writer-task requests that acquired its
dedicated connection (or successfully completed BEGIN IMMEDIATE).
writer_acquisition_timeouts remains specific to the finite-wait main-pool mutex before SQLite
executes; SQLite BEGIN/statement failures are separate stages.
writer_task_begin_busy counts every busy/locked BEGIN IMMEDIATE refusal, matching its
pre-retry meaning: a nonzero value reflects total contention regardless of retry policy.
writer_task_begin_busy_absorbed is a subset of it — refusals a bounded pre-execution retry
absorbed before the request closure ran, so the caller never observed them. A refusal not
absorbed by a retry also surfaces to the caller as the typed, retryable writer_task_begin_busy
stage. The request closure is never retried. audit_append_failures counts
process-wide best-effort audit appends whose storage error was logged and swallowed —
pure-observability rows only. An obligation-bearing row's commit failure (a dispatch outcome, an
unknown-verb row, a git.digest receipt, or a gate denial's own audit row) is never counted here:
it instead fails the dispatch that produced it directly, or — for a denial whose dispatch already
fails independent of the row — is tracked by audit_obligation_append_failures. audit_append_failures
and audit_batch_flush_failures are therefore disjoint for that case; summing them does not
double-count an obligation-bearing generation failure. Zero-wait
checkpoint skips, the diagnostics probe connection, the writer task's one-time lifetime
connection, and the checkpoint task's dedicated long-lived connection (opened once at startup
and reused across ticks) do not inflate the write-traffic acquisition total.
audit_obligation_append_failures counts process-wide failed obligation-bearing audit
submissions, including audit failures for already-denied calls. It is separate from swallowed
best-effort failures. One failed batch generation can contain several such submissions, so
do not sum it with audit_batch_flush_failures as if those were disjoint failures. Runtime
diagnostics supplies the count even without an audit-batch control handle; direct khive-db
collectors return null plus audit_obligation_append_failures_unavailable_reason.
Audit obligations attach to gate denials, dispatch success/failure outcomes, unknown-verb
attempts, and git.digest receipts. This preserves the link between a caller-visible outcome
and its durable audit; whoami and comm.heartbeat have no exemption from outcome audit
commit failure. A configured sink failure can therefore refuse identity and liveness calls
too. Config-lock rows, recall telemetry, and gate-unavailable observability are best-effort.
The existing bounded queue-admission degradation rules are separate from a sink commit
failure and do not change these producer classes. This counter adds visibility without
changing the fail-closed policy or making diagnostics independent of its own configured audit
sink. Channel startup reports a failed quarantine-readiness check with the actual cause; an
audit error on that check does not by itself prove quarantine blob storage is unavailable.
writer_task_request_failures counts dequeued writer-task requests whose processing at the
writer seam terminated in error, regardless of the specific terminal state; a request that never
reached the seam because an earlier request already closed the queue does not count.
writer_task_side_effects_unknown is the subset of those failures whose terminal state left the
request's side effects unprovable. Both are populated directly by khive-db and are therefore
identical for a runtime caller and a direct khive-db caller — unlike the audit_* fields below,
neither is ever null.
audit_batch_flush_failures, audit_degraded_rows, and audit_degraded are additive fields
supplied by the runtime's audit-batch control once one is registered: accepted batch generations
that reached a terminal non-commit outcome after retry, pure-observability rows released without a
commit, and a monotonic process-lifetime degradation flag that either of the first two sets,
respectively. Each carries a matching
_unavailable_reason field and reports null — never a fabricated 0/false — for a direct
khive-db caller or a runtime with no audit-batch control registered.
checkpoint_counters reports checkpoint pressure without making its telemetry another source of
WAL pressure. checkpoint_pressure_elevated_ticks and the episode start/recovery totals are
in-memory observations; checkpoint_lifecycle_append_attempts, append failures, and handoff drops
describe actual persistence work. The checkpoint task appends only episode elevation and recovery
transitions, so sustained pressure does not produce one primary-store write per checkpoint tick.
A finite-wait pooled checkout failure retains its compatibility display text in message, but
the MCP error is a stable object rather than a string:
{
"kind": "unavailable",
"code": "writer_pool_checkout_timeout",
"stage": "writer_pool_checkout_timeout",
"message": "storage: ... timed out ... waiting for sqlite writer connection",
"timeout_ms": 5000,
"capability": "notes",
"operation": "append_note"
}capability and operation are null when the typed SQLite error reaches runtime directly
rather than through a storage capability wrapper. Callers should branch on code or stage,
never on message.
The PASSIVE probe may backfill WAL frames into the database — that is normal checkpoint
I/O and is what the reported checkpointed_frames counts. It never changes logical
state, never escalates to TRUNCATE, never creates a missing database file, and never
deletes WAL-pin sidecar evidence. wal_pin.status reports complete, degraded, or
unavailable; its tagged census.status is independently complete, incomplete, or
unavailable. An incomplete OS walk retains partial PID evidence but states why additional
holders cannot be ruled out. The legacy sibling booleans and PID arrays remain for compatibility.
The holder census is reconciled with a separate, bounded, read-only sidecar pass. A complete census
and conclusive sidecar walk can therefore produce wal_pin.status: "complete"; truncated walks,
unknown sidecar identities, and OS-confirmed holders missing sidecar evidence degrade explicitly.
sidecar_listing_truncated and sidecar_entries_cleanup_would_reap are measured by that pass. The
latter is a forecast: diagnostics never performs the cleanup it reports.
size_composition accounts SQLite pages by individual table or index using aggregate dbstat.
It reports file-wide page/freelist/accounted/unaccounted byte totals plus operational class totals
for ordinary row tables, indexes, FTS storage, vector storage, mixed row-and-embedding tables, and
SQLite internal objects. A table that stores both ordinary columns and an embedding BLOB is kept
in mixed_embedding_bytes: SQLite cannot attribute bytes within a shared page to one column, so
the field is an upper bound for the embedding-bearing table, not a fabricated pure-vector byte
count. Object detail is deterministic and capped at 4,096 rows; objects_truncated and
objects_omitted make cap pressure explicit while the aggregate class totals still cover every
object returned by dbstat. size_composition_error explains an unavailable report.
graph_edge_integrity reports duplicate_edge_id_groups, graph_edges_rows,
graph_edges_seq_rows, and pre_v14_duplicate_edge_state_detected. A non-zero duplicate group
count is the legacy cross-namespace duplicate-ID state that can make a multi-namespace edge cursor
walk lossy. The two row counts are raw evidence, not a parity verdict: list-sequence rows
intentionally survive hard deletion, so the ledger can legitimately contain more rows than the
live edge table. live_entities_carrying_merged_into counts entity rows across all namespaces
that are live while still carrying merged_into, the state an earlier restore left behind before
restore refused merge tombstones; restore names such a row as live_merged_entity instead of
reporting it already live, and this count is where an operator finds the rest.
graph_edge_integrity_error explains a missing integrity section.
The handler additionally annotates graph_edge_integrity with four derived fields:
graph_edges_rows_scope ({"namespaces": "all", "rows": "live_and_soft_deleted"}),
graph_edges_seq_rows_scope ({"namespaces": "all", "rows": "inserted_ids_retained_after_hard_delete"}), graph_edges_seq_minus_graph_edges (the signed
ledger delta), and graph_edges_seq_relationship — one of
ledger_ahead_consistent_with_hard_deletes, equal,
ledger_behind_pre_v14_duplicate_edge_state (a negative delta while the report flags the pre-V14
duplicate-edge state), or ledger_behind_unexpected (a negative delta with no known legacy
explanation).
Sections that cannot be collected (in-memory backend, missing file, unsupported platform) carry
explicit reasons rather than being silently omitted.
request(ops="db_diagnostics()")
List all MCP-callable verbs registered on this server. Internal subhandlers are excluded.
| Param | Type | Required | Notes |
|---|---|---|---|
category |
string | no | Filter: Assertive|Commissive|Declaration|Directive. |
pack |
string | no | Filter by pack name (kg, gtd, memory, brain, comm, schedule, knowledge, session, git, code, workspace, blob). |
request(ops="verbs()")
The result includes the filtered verbs array and total, plus an unfiltered
pack_counts object for every loaded pack. Zero-verb packs remain present in
pack_counts, so callers can distinguish an ontology-only pack from one that
was not loaded.
GTD task lifecycle over notes (kind="task"). Optional; load with
KHIVE_PACKS=kg,gtd.
Create a GTD task (note with kind=task).
| Param | Type | Required | Notes |
|---|---|---|---|
title |
string | yes | Task title. |
status |
string | no | inbox|next|waiting|someday|active (default inbox). Aliases: todo=inbox, in_progress=active, blocked=waiting, later=someday, finished=done. |
priority |
string | no | p0|p1|p2|p3 (default p2). |
assignee |
string | no | Assignee identifier. |
due |
string | no | ISO-8601 due date. |
depends_on |
array<uuid> | no | Blocking task complete UUIDs or unique 8+ hex prefixes resolved in the caller's primary namespace. |
context_entity_id |
uuid | no | Full UUID of a related KG entity. Prefixes are rejected because the stored relationship is an explicit stable reference; Agent responses preserve it canonically. |
tags |
array<string> | no | Tag list. |
idempotency_key |
string | no | Key scoped to the caller's namespace, at most 512 UTF-8 bytes, no NUL. See the replay rule below. |
With idempotency_key, a replay whose stored content matches the original returns the original
task with replayed=true and records no new dependency edges. Different content under the same
key is refused with the conflict error idempotency_key_conflict; its details carry reason,
key and existing_id (the task that holds the key).
request(ops="gtd.assign(title=\"Ship API reference\", priority=\"p1\", assignee=\"agent:docs\")")
List actionable tasks (status next or active) by priority. By default, tasks with
unfinished or structurally broken dependencies are omitted.
| Param | Type | Required | Notes |
|---|---|---|---|
limit |
integer | no | Default 10. |
assignee |
string | no | Filter to this assignee. |
include_blocked |
boolean | no | Include blocked/broken candidates after ready work for diagnosis (default false). |
request(ops="gtd.next(assignee=\"agent:docs\", limit=10)")
Mark a task done (or cancelled) with an optional result note.
Every non-terminal GTD state may move directly to either terminal state. Successful
state changes include audit_persisted; false means the task committed but the
best-effort lifecycle-audit append failed.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Task to complete. |
result |
string | no | Completion note. |
status |
string | no | Terminal status: done (default) or cancelled. |
request(ops="gtd.complete(id=\"<task-id>\", result=\"shipped in PR #600\")")
List tasks filtered by status, assignee, priority.
Each task reports dependency_state (ready, blocked, or broken), actionable,
and a blocked_by array whose entries carry a state of pending, cancelled,
soft_deleted, missing, invalid, different_namespace, or wrong_kind.
| Param | Type | Required | Notes |
|---|---|---|---|
status |
string | no | inbox|next|waiting|someday|active|done|cancelled (aliases as in gtd.assign). |
assignee |
string | no | Filter by assignee. |
priority |
string | no | p0|p1|p2|p3. |
limit |
integer | no | Default 20. |
offset |
integer | no | Default 0. |
request(ops="gtd.tasks(status=\"active\", assignee=\"agent:docs\")")
Explicit GTD status transition with lifecycle validation.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Task to transition. |
status |
string | yes | Target status (same set/aliases as above). |
note |
string | no | Note attached to the transition. |
A task already in the target status is a read assertion, not a lifecycle event: the response
carries transitioned=false, from, to and note: "already in target status", no lifecycle
audit row is written, and a note passed with the request is not persisted, reported as
note_recorded=false.
request(ops="gtd.transition(id=\"<task-id>\", status=\"active\")")
Read-only count of live task timestamps by raw numeric magnitude, for created_at and
properties.archived_at. Every bucket (null, nonnumeric, epoch_zero, 10-, 13- and
16-digit magnitudes, other) is returned, zeros included. No unit is inferred and nothing is
repaired; created_at_gt_archived_at_raw compares raw numbers and is not temporal ordering.
See task-timestamp-census.md.
| Param | Type | Required | Notes |
|---|---|---|---|
namespace |
string | no | Count one visible namespace instead of all. |
request(ops="gtd.census()")
Preview explicit corrections to task created_at, updated_at, or a noncanonical text
status. Pass 1–100 distinct full task IDs in items, with each changed field's exact
stored JSON source under observed and its proposed replacement under value.
The default apply=false changes nothing. With apply=true, each accepted row and
its mandatory lifecycle-audit entry commit together; timestamp units are never
inferred. See explicit historical task repair
for the request shape, refusal reasons, and preserved evidence.
Salience- and decay-weighted memory notes. Optional; load with
KHIVE_PACKS=kg,memory.
Create a memory note with salience and decay.
| Param | Type | Required | Notes |
|---|---|---|---|
content |
string | yes | Memory content. |
salience |
number | no | 0.0–1.0. Type-differentiated default: episodic=0.3, semantic=0.5. |
decay_factor |
number | no | >= 0. Type-differentiated default: episodic=0.02 (~35d half-life), semantic=0.005 (~139d half-life). Higher = faster decay. |
memory_type |
string | no | episodic|semantic (default episodic); no other values accepted. |
source_id |
string | no | UUID or 8-char short ID of the entity/note this memory annotates. |
embedding_model |
string | no | Registered model name; defaults to pack config. |
tags |
array | no | Stored in properties.tags. |
namespace |
string | no | Write namespace override. Default: episodic → caller's namespace, semantic → local. |
idempotency_key |
string | no | Key scoped to the write namespace, at most 512 UTF-8 bytes, no NUL; the legacy spelling key is accepted. See the replay rule below. |
With idempotency_key, a replay whose content matches the stored memory returns the original
memory with replayed=true. Different content under the same key is refused with the conflict
error idempotency_key_conflict; its details carry reason, key and existing_id (the memory
that holds the key).
request(ops="memory.remember(content=\"ADR-016 fixes the DSL grammar\", salience=0.7, memory_type=\"semantic\")")
Recall memory notes with decay-aware hybrid ranking. Each hit carries resolved
(read-model) values — memory_type defaults to episodic when unset; salience and
decay_factor reflect the effective defaults used for ranking.
| Param | Type | Required | Notes |
|---|---|---|---|
query |
string | yes | Semantic recall query. |
limit |
integer | no | Default 10. |
top_k |
integer | no | Overrides limit (max 100). |
min_score |
number | no | Composite score floor, always in [0,1]. Typical production floor 0.3–0.7. |
score_floor |
number | no | Alias for min_score. |
min_salience |
number | no | Salience floor. |
memory_type |
string | no | Filter to this type. |
fusion_strategy |
string | no | rrf|weighted|union|vector_only|keyword_only. |
embedding_model |
string | no | Registered model name; defaults to pack config. |
include_breakdown |
bool | no | Include per-component score breakdown. |
entity_names |
array | no | Names to boost; matches get a 1.3x multiplier. |
full_content |
bool | no | Default true; false truncates content to 200 chars. |
tags |
array | no | Filter by properties.tags. |
tag_mode |
string | no | any (default, OR) or all (AND). |
namespace |
string | no | Exact-match read scope; absent uses the caller's visible namespace set. |
Each result carries serve_attribution (profile, unattributed, or
unspecified). profile also carries served_by_profile_id; unattributed
means a selected profile record was unreadable and downstream feedback must not
fall back to a current binding/default.
Each result also carries canonical full_id; pass it directly to
memory.feedback(target_id=...) in a later request without an extra get.
full_id is present when the resolved output format is json, the builtin
default, under any presentation mode. The auto and table formats omit it
unless the request sets presentation=verbose.
request(ops="memory.recall(query=\"ADR-016 DSL grammar\", limit=5, min_score=0.3)")
Emit explicit feedback on a recalled entity; updates recall-domain posteriors.
| Param | Type | Required | Notes |
|---|---|---|---|
target_id |
uuid | yes | Full UUID of the recalled entity or memory. Prefixes are rejected because feedback must identify one exact record. The acknowledgement returns canonical target_id for reuse. |
signal |
string | yes | useful|not_useful|wrong|explicit_positive|explicit_negative|implicit_positive|implicit_negative|correction. |
request(ops="memory.feedback(target_id=\"<uuid>\", signal=\"useful\")")
Soft-delete memories below a salience threshold and/or past expires_at
(curation-layer, ADR-014).
| Param | Type | Required | Notes |
|---|---|---|---|
min_salience |
number | no | Soft-delete memories strictly below this value. |
before |
integer | no | Soft-delete memories expired at/before this Unix microsecond timestamp; defaults to now; 0 skips the expiry filter. |
namespace |
string | no | Defaults to local. |
dry_run |
bool | no | Default false; when true, counts candidates without deleting. |
request(ops="memory.prune(min_salience=0.2, dry_run=true)")
Run SQLite VACUUM to reclaim space freed by soft-deleted rows. No params.
request(ops="memory.vacuum()")
Recall-tuning profiles: Beta-posterior scoring, profile lifecycle, and the actor/
namespace/consumer-kind resolution table that picks which profile serves a given
caller. Optional; load with KHIVE_PACKS=kg,brain.
Windowed event counts grouped by kind, actor, and verb over the event plane (ADR-103
Stage 1, #724 Ask A). feedback_explicit events are additionally split by
served_by_profile_id, signal, and payload.originating_verb (direct
brain.feedback versus brain.auto_feedback). Legacy feedback rows without the origin
marker fall back to their stored event verb. Events carrying a work_class (today:
phase_started/phase_completed/phase_cancelled payloads, or payload.resource.work_class
on a dispatch audit row) split by counts_by_work_class. Events carrying
payload.resource.cost_unit (ADR-103 Amendment 1, stamped on every successful verb dispatch
since PR #927) sum into total_cost_unit and cost_unit_by_verb; both are omitted, not
zero-filled, when no event in the window carries cost_unit. Events without a cost_unit
(pre-Amendment-1 events, or errored/denied dispatches) simply do not contribute. When
truncated is true, these sums are computed over the fetched page only, same as the other
counts_by_* fields.
| Param | Type | Required | Notes |
|---|---|---|---|
since |
string | yes | Window start, ISO-8601/RFC-3339 datetime. Inclusive. |
until |
string | no | Window end, ISO-8601/RFC-3339 datetime. Exclusive. Defaults to now. |
actor |
string | no | Defaults to the authorized caller. A filter prefixed with actor:, anonymous:, or agent: matches a stored label exactly. Self access compares kind and id; foreign access checks the raw id for actor: and the full label for other reserved kinds against the caller's visible set. Other filters match bare and canonical labels. |
all_actors |
bool | no | Default false. True requests all actors and requires the caller's exact actor id in the serving runtime's [brain] fleet_readers. Cannot be combined with an explicit actor. |
kind |
string | no | Filter to a single EventKind (e.g. "recall_executed"). Omit for all. |
group_by |
array | no | Only ["verb", "actor"], in that order. Omission or null adds no cross; duplicates, reversed/other pairs and unknown dimensions are rejected. |
exhaustive |
bool | no | Default false. Use the existing full-window cursor walk; windows above 2,000,000 events are refused. The live view is best-effort, not a transactional snapshot. |
request(ops="brain.event_counts(since=\"2026-07-01T00:00:00Z\")")
For an actor of kind actor, the canonical stored label is actor: followed by
the unmodified raw principal id, prepending the prefix exactly once. The default
scope also matches a historical bare alias only when the raw id does not begin
with any prefix reserved by RUNTIME_STAMPED_ACTOR_KINDS: actor:, anonymous:,
or agent:. Such prefixed ids match only their canonical stored label. The default
counts_by_actor combines the permitted spellings under one caller-label key, or
has no keys when no events match. Other actor kinds match their exact caller label.
Explicit actor and all_actors=true reads preserve stored actor keys. The caller
label is the actor id for kind actor, otherwise kind:id; an anonymous caller
therefore defaults to anonymous:local. A visible foreign actor is readable only
when explicitly requested. An allowlisted aggregate reader also defaults to its
own events unless it supplies all_actors=true. Client-local configuration cannot
grant aggregate access on a serving daemon.
For example, principal id actor:caller-a writes actor:actor:caller-a and uses
actor="actor:actor:caller-a" for an explicit self read. The filter
actor="actor:caller-a" instead selects the canonical label for principal
caller-a, requiring visibility of that identity even when the filter equals
the caller's raw id. Likewise, a named principal with id anonymous:local uses
actor="actor:anonymous:local" for an explicit self read; the exact filter
actor="anonymous:local" selects the anonymous principal and requires visibility
of that full label. Prefix parsing happens once, and self access compares the
token's kind and id rather than its collapsed caller label.
The reserved-kind list is shared with the gate's ActorRef definition, not a
closed kind enum. Custom kinds outside that list are not covered by the
historical-alias separation guarantee. A new runtime-stamped kind must be added
to the shared list and covered by per-kind event-count tests. See
configuration and
ADR-103 Amendment 5.
Optional group_by=["verb","actor"] returns
counts_by_verb_and_actor: {<verb>: {<actor>: <count>}}. The map contains only
observed cells, with no delimiter joining the keys. A requested empty cross is
{}. Omission or null leaves both cross keys absent. Actor keys use precisely
the existing counts_by_actor rules: default caller aliases coalesce, while an
explicit actor or all_actors=true preserves stored labels.
The cross uses the same filtered events as the marginals. When truncated=true,
only counts_by_verb_and_actor_page_scoped is emitted; the complete-looking
cross key is absent. Existing marginal field names are unchanged. Limits count
event rows, not distinct group cells, so a denser cross cannot trigger a separate
cap. An occupied-cell count cannot exceed the number of events aggregated.
For a dispatch-audit census, request kind="audit" with the cross. That filter
is applied before the existing event cap; the special unfiltered audit/non-audit
budget split is unnecessary on this single-kind path. Use exhaustive=true
when a sampled answer is insufficient, retaining the existing safety bound and
best-effort live-window caveat. window_event_total remains independently read.
request(ops='brain.event_counts(since="2026-09-01T00:00:00Z", until="2026-09-02T00:00:00Z", kind="audit", group_by=["verb","actor"], exhaustive=true)')
List profiles, optionally filtered by lifecycle.
| Param | Type | Required | Notes |
|---|---|---|---|
lifecycle |
string | no | active|inactive|archived; omit for all. |
request(ops="brain.profiles(lifecycle=\"active\")")
Profile metadata, latest snapshot, current state summary.
| Param | Type | Required | Notes |
|---|---|---|---|
profile_id |
string | yes | Profile ID string (e.g. "balanced-recall-v1") — not a UUID. |
request(ops="brain.profile(profile_id=\"implementer-recall-v1\")")
Show which profile would serve a caller context.
| Param | Type | Required | Notes |
|---|---|---|---|
consumer_kind |
string | yes | Verb/operation type about to be performed (e.g. "recall"). |
actor |
string | no | Defaults to the authorized caller. An explicit foreign actor must be in the caller's visible namespace set. |
namespace |
string | no | Default * (wildcard match). |
request(ops="brain.resolve(consumer_kind=\"recall\")")
Resolution retains wildcard binding fallback. For anonymous callers, an omitted
actor remains wildcard-only and does not match explicit local or
anonymous:local bindings. An explicit caller label is permitted; other actor ids
require visibility. There is no all_actors resolution mode.
Move a profile to Active. This is a lifecycle transition; serving reads profile state per request and no background update loop is started.
| Param | Type | Required | Notes |
|---|---|---|---|
profile_id |
string | yes | Profile to activate. |
request(ops="brain.activate(profile_id=\"implementer-recall-v1\")")
Move a profile to Inactive (lifecycle transition; retain state).
| Param | Type | Required | Notes |
|---|---|---|---|
profile_id |
string | yes | Profile to deactivate. |
request(ops="brain.deactivate(profile_id=\"implementer-recall-v1\")")
Move a profile to Archived (read-only, audit-retained).
| Param | Type | Required | Notes |
|---|---|---|---|
profile_id |
string | yes | Profile to archive. |
request(ops="brain.archive(profile_id=\"deprecated-recall-v0\")")
Reset posteriors to priors (preserves event history).
| Param | Type | Required | Notes |
|---|---|---|---|
profile_id |
string | no | Must exist and be active. Defaults to "balanced-recall-v1". |
request(ops="brain.reset(profile_id=\"implementer-recall-v1\")")
Emit a FeedbackExplicit event into the shared log.
| Param | Type | Required | Notes |
|---|---|---|---|
target_id |
uuid | yes | Memory note or entity the feedback applies to. |
signal |
string | yes | Same signal set as memory.feedback. |
served_by_profile_id |
string | no | Profile that served the rated result. |
serve_attribution |
string | no | profile|unattributed|unspecified; unattributed implicit feedback is forced to zero weight, while explicit/correction feedback is rejected. |
section_signals |
object | no | Per-section signals for knowledge_compose profiles: {"section_name": "useful"|"not_useful"|"wrong"}. |
scorer_run_id |
string | no | ADR-081 scorer-pass id; must pair with serve_ledger_id. |
serve_ledger_id |
string | no | ADR-081 brain_serve_ledger row id; must pair with scorer_run_id. |
request(ops="brain.feedback(target_id=\"<uuid>\", signal=\"useful\")")
Emit caller-attributed feedback for one recall result — the convenience verb to call
right after memory.recall instead of hand-building brain.feedback.
| Param | Type | Required | Notes |
|---|---|---|---|
query |
string | yes | The recall query that produced the results. |
results |
array | yes | Recall result objects retained as candidate context: objects with an id field (result UUID or compact id) and optionally served_by_profile_id; bare id strings are rejected. |
target_id |
string | with signal | Full UUID or compact id; must exactly equal one results[].id. |
signal |
string | no | Omission abstains: no feedback event or posterior update. |
served_by_profile_id |
string | no | Profile that served the recall. |
serve_attribution |
string | no | Serve-time tri-state; otherwise copied from the selected result. |
scorer_run_id |
string | no | Forwarded verbatim to brain.feedback; pairs with serve_ledger_id. |
serve_ledger_id |
string | no | Forwarded verbatim to brain.feedback; pairs with scorer_run_id. |
namespace |
string | no | Exact namespace for the event and posterior fold; invalid values fail. |
Top-level serve-attribution fields are one pair and take precedence over the selected
result's pair. If neither top-level field is supplied, both fields are copied from the
selected result together. Feedback events retain the canonical event verb
brain.feedback and record the originating feedback handler in
payload.originating_verb.
request(ops="memory.recall(query=\"x\", limit=5) | brain.auto_feedback(query=\"x\", results=[{\"id\": $prev[0].id}], target_id=$prev[0].id, signal=\"implicit_positive\")")
Emit a PhaseStarted event with work_class="actor_turn" carrying the calling actor and
a timestamp. Callers invoke it once per bounded unit of work (a wake, a turn) so
brain.event_counts's counts_by_work_class["actor_turn"], grouped by actor, gives a
per-actor denominator (e.g. feedback_explicit / actor_turn) that is not biased toward
whichever actor issues the most raw verb calls. Reuses the existing ADR-103 Stage 1
PhaseStarted/work_class vocabulary rather than a new event kind. Best-effort — never
fails the caller's turn.
| Param | Type | Required | Notes |
|---|---|---|---|
label |
string | no | Free-form label for this unit of work (e.g. "wake", "turn"), recorded in the event payload's phase field. Does not affect the work_class. |
request(ops="brain.mark_turn(label=\"wake\")")
Write a row in the profile resolution table.
| Param | Type | Required | Notes |
|---|---|---|---|
profile_id |
string | yes | Must exist. |
actor |
string | no | Default * (all actors). |
namespace |
string | no | Default * (all namespaces). |
consumer_kind |
string | no | Default *; specific values must be declared by a loaded consumer pack. Unknown values are rejected with the valid set. |
priority |
integer | no | Higher wins on multiple matches (default 0). |
request(ops="brain.bind(profile_id=\"implementer-recall-v1\", actor=\"role:implementer\")")
Remove rows from the profile resolution table. At least one filter is required.
| Param | Type | Required | Notes |
|---|---|---|---|
profile_id |
string | no | AND-combined with other filters. |
actor |
string | no | |
namespace |
string | no | |
consumer_kind |
string | no |
request(ops="brain.unbind(actor=\"role:implementer\")")
The result includes removed, the number of matching bindings deleted. A successful
request that matched nothing returns removed: 0. The legacy unbound field carries
the same count for compatibility.
List the authorized caller's rows in the profile resolution table, optionally narrowed by profile, namespace, and consumer kind. The actor filter matches exact binding rows; wildcard fallback belongs to profile resolution, not this listing.
| Param | Type | Required | Notes |
|---|---|---|---|
profile_id |
string | no | |
actor |
string | no | Defaults to the caller label (anonymous:local for an anonymous caller). An explicit foreign actor must be in the caller's visible namespace set. |
namespace |
string | no | |
consumer_kind |
string | no |
request(ops="brain.bindings(consumer_kind=\"recall\")")
The caller's own label is always permitted as an explicit actor filter. There is
no all_actors binding-list mode.
Create a new brain profile with a given name and optional seed priors.
| Param | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Profile ID (alphanumeric + hyphens), must be unique. |
description |
string | no | Human-readable description. |
consumer_kind |
string | no | Default "recall". |
seed_priors |
object | no | Section priors only: {"section_posteriors": {"overview": {"alpha": 2.0, "beta": 2.0}}}. Recall priors cannot be seeded at creation; other top-level keys are rejected. |
request(ops="brain.create_profile(name=\"implementer-recall-v2\", consumer_kind=\"recall\")")
Register an adapter integrity record so the router only composes adapters matching the active base model revision.
| Param | Type | Required | Notes |
|---|---|---|---|
adapter_id |
string | yes | Stable adapter identifier (used as the entity name). |
content_hash |
string | yes | Content hash of the adapter weights. |
base_model_revision |
string | yes | Must match the active revision or registration is rejected. |
metadata |
object | no | Merged into entity properties. |
request(ops="brain.register_adapter(adapter_id=\"lora-v3\", content_hash=\"<sha256>\", base_model_revision=\"2026-07-01\")")
Actor-to-actor messaging with threading. Optional; load with KHIVE_PACKS=kg,comm.
Send a message, optionally threaded.
The atomic outbound/inbound write preserves the full body on both notes. If either copy's
embedding input is bounded, the successful response includes the standard warnings advisory.
| Param | Type | Required | Notes |
|---|---|---|---|
to |
string | yes | Actor label, e.g. "lambda:leo". Both copies land in the caller's namespace; no cross-namespace write occurs. |
content |
string | yes | Non-empty message body. |
subject |
string | no | Optional subject line. |
thread_id |
uuid | no | Optional full thread UUID. Prefixes are rejected because a thread root is an explicit stable reference. Accepted complete spellings normalize to canonical lowercase dashed form. |
self_send |
bool | no | Default false. Required when to matches the configured sender actor; otherwise the send is rejected. The anonymous local fallback is exempt. Use true only for an intentional note to self. |
request(ops="comm.send(to=\"lambda:leo\", subject=\"PR ready\", content=\"#600 is open for review\")")
Returns {id, full_id, thread_id, ...}. full_id and thread_id remain canonical
36-character UUIDs in Agent mode; pass the returned thread_id unchanged to a later send.
Confirm the internal inbound sibling for a comm.send or comm.reply
outbound UUID. This is a read-only exact correlation lookup; it does not infer
delivery from content and does not report later SMTP or other external
transport status. The matching inbound note must belong to the caller's
namespace and carry the caller as from_actor.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Full full_id from a successful send/reply, or outbound_id surfaced by an ambiguous atomic-write error. Prefixes are rejected; the UUID is the correlation key. |
Returns {id, status, delivered, inbound_count}. A successful lookup is
conclusive: status is delivered when inbound_count > 0, otherwise
undelivered. A lookup error leaves the delivery outcome uncertain.
Ordinary atomic-write failures leave neither copy and do not require this
lookup. Loss of the entire MCP response also loses the generated UUID and is
outside this operation's contract.
request(ops="comm.delivered(id=\"<full-outbound-uuid>\")")
List and page through the caller's filtered inbound messages (default) or sent
history (box="sent").
The response keeps the inbox envelope and adds explicit bounded-count metadata.
unread_count is the mailbox-wide unread count for the caller — independent of
the page window and of status and sender filters — and is exact below
unread_count_cap (1,000). When unread_count_saturated is true, the count
equals the cap and means "at least this many"; false means it is exact. Sent
rows report zero and false.
With wait_ms, an initially empty fully filtered page waits for a newly
committed matching message and otherwise returns at the deadline.
| Param | Type | Required | Notes |
|---|---|---|---|
limit |
integer | no | Default 20, max 200. |
box |
string | no | inbox (default)|sent. Sent rows are scoped to the caller. |
offset |
integer | no | Default 0; offset after every supplied filter. |
status |
string | no | Inbox-only: unread (default)|read|all. |
wait_ms |
integer | no | Long-poll only when the initial page is empty; default 0, max 30,000. |
from_actor |
string | no | Exact sender; mutually exclusive with from_prefix. |
from_prefix |
string | no | Sender prefix; mutually exclusive with from_actor. |
exclude_from_actor |
string | no | Exclude an exact sender actor label. |
to_actor |
string | no | Sent-only exact recipient actor filter. |
since |
string | no | Inclusive RFC 3339 lower bound on top-level created_at. |
before |
string | no | Exclusive RFC 3339 upper bound on top-level created_at. |
subject_contains |
string | no | Case-insensitive non-empty subject substring; null subjects do not match. |
content_contains |
string | no | Case-insensitive non-empty content substring. |
fields |
array | no | Non-empty message-field projection shared with comm.thread. |
request(ops="comm.inbox(limit=10)")
request(ops="comm.inbox(status=\"all\", content_contains=\"timeout\", offset=200)")
request(ops="comm.inbox(box=\"sent\", to_actor=\"lambda:leo\", since=\"2026-08-01T00:00:00Z\", fields=[\"id\",\"subject\",\"sent_at\"])")
request(ops="comm.inbox(limit=10, wait_ms=30000)")
The long-poll wake is process-local and carries no payload. Every wake re-runs the same scoped query, and the response shape is identical to an immediate inbox call.
Every returned message uses the hyphenated full UUID for id, so the value is
always accepted unchanged by comm.read, comm.reply, or comm.thread, even
when two messages share an eight-character prefix. full_id remains an alias
for compatibility, while short_id is the compact display-only prefix.
Responses also carry offset, has_more, and next_offset; repeat the same
filtered call with each non-null next_offset to enumerate every match without
marking it read. All filters are ANDed. Time bounds use response created_at,
not optional transport sent_at metadata.
fields accepts the ordinary top-level message keys plus stable property
aliases (comm_schema_version, from_actor, to_actor, thread_id,
sent_at, outbound_ref, sent_by_process). Unknown names and an empty list
are errors. Omit it for the existing full-body response.
Count-only view of the caller's unread inbound messages — the same filter as
comm.inbox(status="unread"), without message payloads. Takes no parameters.
Returns {count, count_cap, count_saturated, actor} with the same 1,000-row
bound as the inbox metadata: count_saturated=false is exact, while true
means count == count_cap is a lower bound.
request(ops="comm.unread()")
Fetch and mark one or more inbound messages. Successful results return subject, content,
from, to, direction, and created_at alongside the existing acknowledgement fields.
Pass body=false for the prior acknowledgement-only shape. Failed or indeterminate marks do
not add message fields. Outbound messages cannot be marked read. Mark writes
are best-effort: validation errors (not found, wrong kind, outbound direction, wrong addressee)
remain fatal, but a post-read mark failure returns status: "failed", read: false, and
mark_error. A write whose execution seam terminated after being accepted (so it may already
have applied) instead returns status: "unknown", read: null, and mark_error — check the
message's current state through comm.inbox before re-issuing; re-issuing is safe, since marking
a message read is idempotent. Successful items carry status: "success"; inspect each result and
re-issue failures (or unresolved unknowns) later.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
string | conditional | One 8-char prefix or full UUID; mutually exclusive with ids. |
ids |
array of string | conditional | 1-500 IDs; mutually exclusive with id. All targets validate up front. |
body |
bool | no | Defaults to true; false omits top-level message fields. |
request(ops="comm.read(id=\"<message-id>\")")
request(ops="comm.read(ids=[\"<message-id-1>\", \"<message-id-2>\"])")
request(ops="comm.read(id=\"<message-id>\", body=false)")
Exactly one of id or ids is required. The bulk response contains ordered
results plus requested_count, unique_count, marked_count, unknown_count, and
failed_count, with aggregate status=success|partial|failed|unknown. Bulk updates are not atomic
across messages: validation errors
reject the call before any write, while later storage errors appear in each
item's read and optional mark_error.
Canonical named bulk mark-read. It accepts the same inbound targets and returns the same bulk
summary and acknowledgement fields as comm.read(ids=[...]), while adding an all-or-nothing
mutation mode. It does not add message fields.
| Param | Type | Required | Notes |
|---|---|---|---|
ids |
array of string | yes | 1-500 prefixes or full UUIDs. All targets validate up front; duplicate resolved IDs update once. |
atomic |
bool | no | Default false. True commits every unique mark in one transaction or rolls the full set back. |
request(ops="comm.mark_read(ids=[\"<message-id-1>\", \"<message-id-2>\"])")
request(ops="comm.mark_read(ids=[\"<message-id-1>\", \"<message-id-2>\"], atomic=true)")
With the default atomic=false, complete prevalidation is followed by the existing best-effort
per-target storage updates; inspect read and mark_error. With atomic=true, every target is
rechecked for namespace, message kind, inbound direction, and addressee inside one transaction.
A failed recheck or transaction statement returns an operation error and leaves every target
unchanged. A side_effects_unknown storage error means the transaction stayed indivisible but its
commit outcome could not be confirmed; callers must not blindly retry that case. The affected writer
is retired instead of being reused.
Retrieve content separately through comm.inbox or comm.thread.
Reply to a message, threading linkage.
Replies use the same full-source storage and embedding-truncation warnings contract as
comm.send.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | 8-char prefix or full UUID of the message being replied to. |
content |
string | yes | Non-empty reply body. |
request(ops="comm.reply(id=\"<message-id>\", content=\"On it.\")")
Retrieve all messages in a conversation thread, ordered chronologically.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Thread root: 8-char prefix or full UUID of the originating message. |
limit |
integer | no | Default 100, max 500. |
order |
string | no | asc (default)|desc. |
after |
string | no | Message-id or RFC 3339 cursor in the chosen order. |
fields |
array | no | Same strict message-field projection as comm.inbox. |
request(ops="comm.thread(id=\"<thread-root-id>\")")
request(ops="comm.thread(id=\"<thread-root-id>\", fields=[\"id\",\"from_actor\",\"sent_at\"])")
Strictly read-only poll for new inbound message metadata and a stale-unread count. No
read-flag mutation, no writes: designed for monitors polling every ~30 seconds, served by
a single cheap indexed query. Returns a cursor_us high-water mark, a stale_unread_count
of inbound messages unread past the staleness window, and a new_messages array of up to
100 inbound rows {id, created_at_us, from_actor, subject?} newer than since_us.
cursor_us/since_us is an opaque, monotonically increasing token, not a Unix microsecond
timestamp: round-trip whatever the previous comm.probe response returned as the next
call's since_us, and omit it for a baseline-first probe.
| Param | Type | Required | Notes |
|---|---|---|---|
actor |
string | yes | Actor label whose inbound mail is probed. |
since_us |
integer | no | Opaque cursor from a prior response's cursor_us. |
stale_minutes |
integer | no | Staleness window for the unread count (default 20). |
request(ops="comm.probe(actor=\"lambda:leo\")")
request(ops="comm.probe(actor=\"lambda:leo\", since_us=42)")
Read-only per-channel health snapshot. Returns the daemon-persisted heartbeat row for
every known channel, including poll_interval_secs, nullable advisory stalled, and
the live quarantined_count. Top-level quarantined_count covers the namespace-wide
parked backlog; unattributed_quarantined_count reports legacy rows that lack a complete
channel identity. Quarantine-only channel entries have nullable heartbeat fields and do
not fabricate daemon ownership. The channel union is capped at 200: heartbeat rows take
precedence, then quarantine-only identities fill remaining capacity in lexical channel
identity order. Top-level quarantine totals remain namespace-wide when entries are omitted.
For current rows with no known failure, stalled becomes true after three missed nominal
intervals; it is null for legacy/malformed rows or active failure/backoff state. This is
not a computed healthy or authoritative supervisor verdict. Health judgment belongs to
the caller. Rows are read from the caller's injected namespace (namespace=, defaulting
to local like every other comm verb). The shipped poll loop explicitly writes its
heartbeats to local; authorized per-tenant writers can write their own namespace. The
response echoes the namespace actually read in a namespace field, so an empty
channels array is scoped unambiguously. See the
communication guide for the full response contract.
To recover, page comm.inbox(status="all"), inspect full rows for
properties.quarantined, and fetch a selected row with get(id=...).
delete(id=...) removes it from the parked count; delete(id=..., hard=true)
permanently purges it. There is deliberately no automatic "release as trusted" path.
Generic message create/update mutations cannot set channel_kind, channel_slug, or
quarantined; those transport-owned fields are established only by comm.ingest.
No parameters.
request(ops="comm.health()")
Time-triggered reminders and deferred verb dispatch. Optional; load with
KHIVE_PACKS=kg,schedule. Add comm to create reminders.
Create a time-triggered reminder.
| Param | Type | Required | Notes |
|---|---|---|---|
content |
string | yes | Non-empty reminder message. |
at |
string | yes | RFC 3339 trigger time, e.g. "2026-06-01T09:00:00Z". |
repeat |
string | no | daily|weekly|monthly, every:<N><s|m|h|d> (e.g. every:15m), or five-field cron in UTC. |
request(ops="schedule.remind(content=\"check PR #600 CI\", at=\"2026-07-05T09:00:00Z\")")
Schedule a future verb dispatch.
| Param | Type | Required | Notes |
|---|---|---|---|
action |
string | yes | One replayable verb call, e.g. "gtd.assign(title=\"follow up\")". |
at |
string | yes | RFC 3339 trigger time. |
repeat |
string | no | Same recurrence grammar as schedule.remind. |
request(ops="schedule.schedule(action=\"gtd.next(assignee=\\\"agent:docs\\\")\", at=\"2026-07-05T09:00:00Z\")")
List upcoming scheduled events.
| Param | Type | Required | Notes |
|---|---|---|---|
from |
string | no | RFC 3339 window start; omit to start from the earliest pending event. |
to |
string | no | RFC 3339 window end; omit for all future events. |
limit |
integer | no | Default 20, max 200. |
request(ops="schedule.agenda(limit=10)")
Cancel a scheduled event.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Complete UUID or unique 8+ hex prefix of the scheduled event. Prefix resolution searches the caller's primary namespace. |
request(ops="schedule.cancel(id=\"<event-id>\")")
The knowledge-atom corpus: bulk ingest, TF-IDF + embedding search, domain composition,
section-level review/dispute, and KG-sugar verbs for citing sources. Optional; load
with KHIVE_PACKS=kg,knowledge.
Bulk insert or update knowledge atoms by slug.
| Param | Type | Required | Notes |
|---|---|---|---|
atoms |
array<object> | yes | {slug, name, content, tags?, properties?, finalized?} per atom. |
chunk_size |
integer | no | Client-side chunking hint, max 5000. |
request(ops="[{\"tool\":\"knowledge.upsert_atoms\",\"args\":{\"atoms\":[{\"slug\":\"rope\",\"name\":\"RoPE\",\"content\":\"Rotary position embedding...\"}]}}]")
Bulk insert or update domain groupings of atoms.
| Param | Type | Required | Notes |
|---|---|---|---|
domains |
array<object> | yes | {slug, name, description?, tags?, members?} per domain. |
request(ops="[{\"tool\":\"knowledge.upsert_domains\",\"args\":{\"domains\":[{\"slug\":\"attention\",\"name\":\"Attention mechanisms\"}]}}]")
Fetch a single atom or domain by full UUID, exact slug, or unique short prefix, in that order. Exact slug lookup uses the caller namespace; UUID and prefix forms are namespace-agnostic by-ID reads.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Atom/domain full UUID, exact caller-namespace slug, or unique 8+ hex UUID prefix. |
include_sections |
bool | no | Include the atom's sections under a sections key (ignored for domains). Each section: id, atom_id, namespace, section_type, heading, content, content_hash, status, tokens, sort_order, created_at, updated_at, ordered by sort_order, created_at, id. Default false. |
request(ops="knowledge.get(id=\"rope\", include_sections=true)")
Paginated listing of atoms or domains. Offset pages are ordered by
created_at DESC, id DESC. For stable full-store traversal, start cursor mode
with after="", then reuse each non-null next_after; cursor pages are ordered
by created_at ASC, id ASC and are not shifted by concurrent inserts.
| Param | Type | Required | Notes |
|---|---|---|---|
type |
string | no | atom|domain (default atom). |
limit |
integer | no | Default 20, max 500. |
offset |
integer | no | Legacy offset pagination; mutually exclusive with after. |
after |
string | no | "" starts keyset mode; otherwise the full UUID from next_after. Missing, wrong-type, and out-of-namespace cursors fail. |
fields |
array<string> | no | Strict non-empty projection. Use ["id","slug"] for a key-only walk; unrequested content is not selected from storage. |
status |
string/array | no | Atom status filter. Reuse it throughout a cursor walk. |
exclude_status |
string | no | Atom exclusion filter when status is absent. Reuse it throughout a cursor walk. |
request(ops="knowledge.list(type=\"domain\", limit=50)")
request(ops="knowledge.list(type=\"atom\", fields=[\"id\",\"slug\"], after=\"\", limit=500)")
Cursor traversal is live, not a snapshot. Inserts behind an issued boundary
belong to a fresh walk; inserts ahead may extend the current walk. Existing
rows are not shifted, skipped, or duplicated. Responses include machine-readable
order and, in cursor mode, next_after. Stop when next_after is null;
cursor pages carry no total (counting is a full scan per page), offset pages do.
Soft-delete atoms by slug or ID.
| Param | Type | Required | Notes |
|---|---|---|---|
ids |
array<string> | yes | Atom slugs or UUIDs. |
request(ops="knowledge.delete_atoms(ids=[\"stale-atom-slug\"])")
Corpus statistics: atom count, domain count, coverage. No params.
request(ops="knowledge.stats()")
Backfill atom embeddings.
The response includes truncation_by_model, keyed by every model that completed embedding work.
Each truncation value contains truncated and discarded_bytes counters derived from the actual
embedding outcomes; atom source content remains complete in SQL and FTS.
This verb does not rebuild the FTS indexes. Rebuilding fts_knowledge/fts_sections is a
whole-database operation independent of the caller's namespace, and the ordinary verb has no
per-caller cost admission to bound it, so that rebuild is reachable only through the
kkernel reindex operator CLI (--rebuild-fts), which reports the indexes rebuilt, elapsed
time, and the rank-1 integrity-check outcome.
| Param | Type | Required | Notes |
|---|---|---|---|
ids |
array<string> | no | Atom slugs/IDs to index; omit to index all. |
batch_size |
integer | no | Default 500, max 1000. |
insert_only |
bool | no | Deprecated no-op, accepted for API compatibility only. |
rebuild_ann |
bool | no | Rebuild the in-memory Vamana ANN index (default false). |
request(ops="knowledge.index(rebuild_ann=true)")
Budget-constrained knapsack selection of scored candidates.
| Param | Type | Required | Notes |
|---|---|---|---|
candidates |
array<object> | yes | {id, score, size, content?, category?} per candidate. |
budget |
integer | yes | Token/size budget for the selected set. |
min_score |
number | no | Default 0.0. |
category_weights |
object | no | Per-category score multipliers. |
request(ops="[{\"tool\":\"knowledge.fold\",\"args\":{\"candidates\":[{\"id\":\"a\",\"score\":0.8,\"size\":400}],\"budget\":4000}}]")
TF-IDF ranked search over the knowledge corpus with embedding rerank (default when an embedder is configured). Draft and deprecated atoms are excluded by default. Scores are request-relative ranking values, not calibrated relevance probabilities. Interpret rank together with the candidate and score provenance described below.
| Param | Type | Required | Notes |
|---|---|---|---|
query |
string | yes | Search query text. |
type |
string | no | atom|domain (default both). |
include_drafts |
bool | no | Default false; no-op when status is set. |
status |
string | no | Exact status filter: draft|reviewed|deprecated; overrides include_drafts. |
exclude_status |
string | no | Exclude an exact status; only used when status unset. |
role |
string | no | Agent role hint, prepended to the query for scoring. |
limit |
integer | no | Default 10, max 100. |
min_score |
number | no | Default 0.0. |
weights |
object | no | {w_name, w_tags, w_content, w_exact_name, w_bigram, expand_discount, coverage_alpha}. |
decompose |
bool | no | Default false; enables query decomposition. |
decompose_threshold |
integer | no | Default 4 non-stop terms to trigger decomposition. |
intersection_bonus |
number | no | Default 0.25; score multiplier for multi-sub-query hits. |
rerank |
bool | no | Default true; embedding rerank; no-op with no embedder configured. |
rerank_alpha |
number | no | Default 0.7 (TF-IDF-dominant blend). |
The response is {results, total, candidate_provenance, ...}. A genuine FTS miss does
not scan or rank unrelated recent corpus rows. candidate_provenance.lexical reports:
matched: eligible lexical candidates were found.exact_name: FTS found no match, and a query with no scoreable terms recovered an eligible atom by its exact normalized slug.no_match: no lexical match was found in the caller's namespace.filtered: lexical matches were removed by eligibility, such as kind or status filters.partial_timeout: a timed-out fetch retains eligible candidates, or decomposed passes mix completed and timed-out outcomes.timed_out: a fetch times out with no retained candidates, or every decomposed pass does so. Completing empty terms before a timeout does not make a fetch partial.
These states supplement degraded.lexical_timeout and any public timeout details. A lexical
stage timeout does not by itself mean the request's broader read deadline has expired.
Short queries such as AI use the pack's import slug convention for this indexed recovery.
A custom slug outside that convention is outside the guarantee. The probe shares the lexical
pass's remaining deadline and namespace, status, and kind filters; a role hint does not change
the raw query used for lookup.
candidate_provenance.terms_truncated is true when a lexical pass exceeds the request's
shared allowance of 32 distinct expanded terms. The full query and both decomposed passes
draw from the same allowance; a repeated term in a later pass counts again. Admission uses
deterministic spelling order before rarest-first scheduling. Frequency, rowid, eligibility,
and namespace-existence probes all stay within those admitted terms, while retaining their
own row and time limits. The lexical state describes only admitted terms: a match reachable
only through an untested term does not make a truncated miss filtered.
candidate_provenance.fallback is ann only when the returned set has ANN evidence and
no returned result has lexical evidence; otherwise it is none, including for an empty
result. Each knowledge.search result includes score_provenance:
{
"sources": ["lexical", "ann"],
"embedding_rerank": true,
"normalization": "s_over_s_plus_1",
"calibrated": false
}sources is a stable-order subset of lexical and ann; a hit found by both retains
both labels after RRF fusion. embedding_rerank records whether a successful embedding
rerank transformed that result's score. Search monotonically squashes the score with
s / (s + 1) before applying its status multiplier and final min_score filter. Scores
remain useful for ordering and thresholding within a call, but no fixed numeric band
establishes relevance across queries.
request(ops="knowledge.search(query=\"FastAPI JWT middleware\", rerank=true, limit=10)")
Suggest relevant knowledge domains for a query. Draft/deprecated domain atoms excluded by default.
| Param | Type | Required | Notes |
|---|---|---|---|
query |
string | yes | Orientation query text. |
role |
string | no | Agent role hint. |
limit |
integer | no | Default 8, max 100. |
request(ops="knowledge.suggest(query=\"async middleware retry circuit breaker patterns\", role=\"implementer\")")
Compose a markdown briefing from selected knowledge domains and atoms.
| Param | Type | Required | Notes |
|---|---|---|---|
domain_ids |
array<string> | no | Domain UUIDs/slugs whose member atoms to include. |
atom_ids |
array<string> | no | Atom UUIDs/slugs to include directly. |
query |
string | yes | Reranks the selected atom bodies. |
namespace |
string | no | Exact namespace for all compose and profile-weight reads. |
request(ops="knowledge.compose(query=\"FastAPI JWT middleware validation patterns\", domain_ids=[\"attention\"])")
Upsert sections for an atom without wiping other sections.
The response combines the inline section and atom refresh outcomes in truncation_by_model and
includes the standard warnings advisory when any model bounded an embedding input. Stored section
and atom content remains complete.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Atom UUID or slug. |
sections |
array<object> | yes | [{section_type, content, heading?, sort_order?}]. section_type is a closed enum: overview|core_model|boundary_conditions|formalism|operational_guidance|examples|failure_modes|expert_lens|references|other. content must be >= 80 characters. |
request(ops="[{\"tool\":\"knowledge.edit\",\"args\":{\"id\":\"rope\",\"sections\":[{\"section_type\":\"overview\",\"content\":\"Rotary position embedding rotates query/key vectors by an angle proportional to position...\"}]}}]")
Validate and ingest atlas markdown file(s) with canonical-frontmatter or stable path identity.
| Param | Type | Required | Notes |
|---|---|---|---|
path |
string | yes | Filesystem path to a .md file or bounded directory tree. |
format |
string | no | Only atlas_md supported (default). |
chunk_strategy |
string | no | section (atom plus section rows) or atom (markdown body, no section rows). |
request(ops="knowledge.import(path=\"/path/to/atlas/rope.md\")")
Leading delimiter-bounded YAML frontmatter is metadata rather than body content. Its agreeing
id/atlas_id/atlas-id aliases take identity precedence; name/title, tags, nested
properties, and other metadata map into atom fields. Without a canonical ID, directory slugs use
normalized root-relative components joined by --. Source paths are retained in
properties.source_path. Traversal and source validation complete before writes, final-slug and
existing-identity conflicts fail closed, and symlinks are not followed. Root directory symlinks
are rejected with or without a trailing separator. Entry, depth, and file-limit errors include the
exact failing path plus current and configured traversal counts. Successful responses add
entries_visited, files_discovered, files_skipped, traversal_errors, sections_discovered,
and sections_skipped to the existing import counters.
Mark a section as disputed and increment the atom's dispute_count.
| Param | Type | Required | Notes |
|---|---|---|---|
atom_id |
string | yes | Atom UUID or slug. |
section_type |
string | yes | Section type to challenge. |
content_hash |
string | no | Required when more than one eligible section of that type exists. |
reason |
string | no | Optional challenge reason. |
request(ops="knowledge.challenge(atom_id=\"rope\", section_type=\"formalism\", reason=\"formula sign error\")")
Resolve a disputed section and decrement the atom's dispute_count.
| Param | Type | Required | Notes |
|---|---|---|---|
atom_id |
string | yes | Atom UUID or slug. |
section_type |
string | yes | Section type to adjudicate. |
content_hash |
string | no | Required when more than one disputed section of that type exists. |
resolution |
string | yes | accept (marks verified) or reject (marks reviewed). |
request(ops="knowledge.adjudicate(atom_id=\"rope\", section_type=\"formalism\", resolution=\"accept\")")
Register a concept entity with optional domain and tags.
| Param | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Concept name. |
description |
string | no | Optional description. |
domain |
string | no | Folded into properties.domain. |
tags |
array<string> | no | Optional tag list. |
request(ops="knowledge.learn(name=\"GQA\", domain=\"attention\", description=\"Grouped-query attention\")")
Link a concept to the paper or source that introduced it.
| Param | Type | Required | Notes |
|---|---|---|---|
concept_id |
uuid | yes | Concept entity ID. |
source_id |
uuid | yes | Source entity ID; must be kind=document, kind=person, or kind=org (introduced_by edge rule). |
weight |
float | no | Defaults to 1.0. |
request(ops="knowledge.cite(concept_id=\"<concept-uuid>\", source_id=\"<paper-uuid>\")")
List concepts filtered by domain or free-text query.
With a non-null query (including an empty string), the response contains
results and candidate_window_count, with no total. The count is the number
of candidates remaining after hydration and optional domain filtering, before
truncating output to limit. Search requests at most four times the effective
output limit; the count is neither a full matching corpus count nor a pagination
promise. Candidates excluded by the domain filter are not replaced.
Without query, or with query=null, the response retains results and total:
total counts all matching caller-visible concepts before the output limit.
This branch does not include candidate_window_count. Any limit-report fields
retain their own meanings and are independent of both counts.
| Param | Type | Required | Notes |
|---|---|---|---|
domain |
string | no | Filter to concepts tagged with this domain. |
query |
string | no | Free-text search across name + description. |
limit |
integer | no | Default 20, max 100. |
request(ops="knowledge.topic(domain=\"attention\")")
Record judgments on live knowledge atoms/domains and optionally update section weights.
| Param | Type | Required | Notes |
|---|---|---|---|
signal |
string | one judgment required | useful, not_useful, or wrong; requires target_id. Scalar-only feedback does not train sections. |
section_signals |
object | one judgment required | Non-empty {section_type: signal} map. Requires an attributed caller. |
target_id |
string | with signal |
Live atom/domain UUID or unique undashed hex prefix of at least 8 characters; KG entity/note IDs and slugs are refused. Optional for section-only feedback. |
served_by_profile_id |
string | no | Serving profile, ahead of pack configuration and actor/namespace knowledge_compose binding. Profile feedback requires an attributed caller. |
request(ops="knowledge.feedback(target_id=\"5b825dc5-8652-4ca8-9c68-4b2f01011673\", signal=\"wrong\", section_signals={\"overview\": \"not_useful\"})")
Supplied targets are resolved and recorded in every tier. Sections update the
selected brain profile through a trusted in-process hook, or the namespace-local
prior when no profile resolves. No scalar signal is invented for section-only
calls. Regular brain.feedback remains KG-target-only.
The knowledge event commits before profile learning, which may use another database. A later profile failure reports the committed knowledge event ID and an unconfirmed profile outcome; inspect before retrying. The error does not roll back the already-recorded knowledge judgment.
Cross-provider agent-session continuity records. Optional; load with
KHIVE_PACKS=kg,session.
Persist an agent-session record as a session note.
| Param | Type | Required | Notes |
|---|---|---|---|
content |
string | yes | Verbatim transcript or summary content. |
title |
string | no | Stored as note.name. |
provider |
string | no | Provider label, e.g. codex, claude_code, openai. |
provider_session_id |
string | no | Provider-native continuity anchor. |
tags |
array<string> | no | Stored in properties.tags. |
request(ops="session.store(content=\"...\", provider=\"claude_code\", title=\"pages revamp session\")")
List stored sessions newest first.
Every summary includes canonical full_id for direct reuse with
session.resume or session.export across requests. As with other records,
full_id is present under the default json output format and is omitted by
format=auto and format=table unless the request sets presentation=verbose.
| Param | Type | Required | Notes |
|---|---|---|---|
limit |
integer | no | 1–200, default 20. |
offset |
integer | no | Default 0. |
provider |
string | no | Exact filter on properties.provider. |
agent_id |
string | no | Exact filter on legacy properties.agent_id. |
since |
string | no | Inclusive RFC 3339 lower bound on session creation. |
request(ops="session.list(provider=\"claude_code\", limit=10)")
Fetch one session's full content by UUID or 8+ hex prefix.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Full UUID or 8+ hex short prefix. |
request(ops="session.resume(id=\"<session-id>\")")
Serialize one stored session as json or markdown.
| Param | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Full UUID or 8+ hex short prefix. |
format |
string | no | json|markdown, default json. |
request(ops="session.export(id=\"<session-id>\", format=\"markdown\")")
Search mirrored message text within the request's resolved tenant scope. The public handler currently refuses until transcript deletion and resume/export continuity support are available. Serving multiple principals also requires authenticated connection identity.
| Param | Type | Required | Notes |
|---|---|---|---|
query |
string | yes | Words to match in mirror text. |
limit |
integer | no | 1–200, default 20. |
since |
string | no | Inclusive RFC 3339 message creation lower bound. |
source |
string | no | Exact source; unknown returns migration orphans when named. |
cwd |
string | no | Exact session working directory. |
The namespace and account fields are not parameters. The identity and scope contract
specifies the scoped key, migration, and search result identity.
Report database-wide row counts and allocated bytes for session mirror tables,
plus database-file and WAL sizes. Requires SQLite dbstat. See the
maintenance contract.
Run explicit SQLite compaction. The result reports ok: true once VACUUM
commits. Before/after byte and page figures are returned when the post-commit
read succeeds; if the request read deadline expires during VACUUM, the after
figures are null with post_vacuum_metrics_status: "unavailable_after_commit". See the maintenance contract.
The exec pack uses immutable khive-tree/v1 manifests, also consumed by Git tree
operations. See ADR-181 for run and
sandbox semantics.
| Verb | Parameters | Result |
|---|---|---|
exec.tree |
entries: [{path, ref, mode}] |
{tree} |
exec.tree_get |
tree |
{tree, entries} |
exec.tree_put |
tree, nonempty edits: [{path, ref|content|delete, mode?}] |
{tree, base, entries, changed} |
exec.tree_diff |
base, head |
{base, head, changed} |
Entry modes are decimal 644 (file), 755 (executable file) or 120000
(symlink). A symlink's blob holds its literal target bytes, without an added
newline or normalization. Entry paths must be relative and normalized, with no
duplicates or entries below a file or symlink path. Empty entries is an empty
tree. The schema string remains khive-tree/v1; existing file-only manifests
remain valid.
A put edit supplies exactly one of ref, content (UTF-8 text), or
delete: true. Use ref for arbitrary target bytes. Its optional mode preserves
an existing mode or defaults to 644 for a new path; deletes cannot carry a mode.
Retargeting a link or switching between a symlink and a file is modified.
Unsupported modes, duplicate edit paths and deletion of a missing path refuse
the call without publishing a new tree.
exec.run materializes real symlinks, including absolute or escaping targets.
Seatbelt constrains access to resolved targets; declared_write_paths names
tree-relative paths and does not expand sandbox access. Capture records link
targets without following them, never descends through directory symlinks, and
reports link additions, retargeting, removal and mode changes in changed.
Sockets, FIFOs and devices remain skipped.
git.diff(input_kind="trees") preserves symlink mode 120000 and target blobs,
so its patches use Git's native symlink and file-conversion representation.
This does not change the separate git.checkout symlink-refusal contract.
The entries below cover the ingest and write surface; the dev-loop verbs
(git.checkout, git.diff, git.gates, git.receipts, git.reconcile, git.status,
git.log, git.init, git.pr_open, git.pr_review, git.pr_merge) are specified in
ADR-182 and its amendments.
Git-history ingester plus a hardened write surface (ADR-088,
ADR-088 Amendment 1,
ADR-088 Amendment 2, ADR-108).
Optional; load with KHIVE_PACKS=kg,git. Also registers the commit /
issue / pull_request note kinds, used by git.digest below and by the kkernel git-ingest CLI (both drive the same underlying ingest core, so ingest enrichment —
readable names, Closes #N reference edges, parent→child commit precedes edges —
applies identically either way).
Walk a local repository path or clone/fetch a remote https:// URL, then ingest commits
and (when source-bound gh repo view <owner/repo> resolves the GitHub repository derived
from the canonical source or local origin) issues and pull requests as provenance notes,
resolving or auto-creating the repo-anchor
project entity. Bounded and cursor-resumable: call again with the same
source/project while the response's done field is false.
| Param | Type | Required | Notes |
|---|---|---|---|
source |
string | yes | A local filesystem path (must contain .git) or an https:// URL. Any https host is accepted; issue/PR work requires a successful source-bound GitHub probe, otherwise the pass degrades to commits-only with structured skips. ssh://, git://, http://, and scp-shorthand (user@host:path) sources are rejected. |
project |
string | no | UUID or 8+ hex prefix of the repo-anchor project entity. When absent, resolution is slug-first through properties.repo_slug, then exact and normalized properties.repo_url reconciliation; a new anchor is created only when no identity evidence matches. Names are never a match key. See project_id and project_created. |
max_items |
integer | no | Bounded work for this call, counted across commits + issues + PRs (default 500, clamped to 1..=2000). Cursor-resumable: call again while the response's done field is false. |
include |
array<string> | no | Which record kinds to ingest this call: any of commits | issues | pull_requests (default: all three). |
request(ops="git.digest(source=\"https://github.com/org/repo\", max_items=500)")
The result includes writes_refused, a per-call count of record writes blocked by the
secret gate, and write_refusals, one safe structured diagnostic per refusal. Each
diagnostic names the attempted verb, the provenance record_kind and natural
record_key, plus the detector and a masked excerpt; rejected content is never returned.
Because a digest continues after a per-record refusal, callers that require a clean run
should assert writes_refused == 0 in addition to waiting for done == true.
Per-source coverage is machine-readable via sources and history_exhausted. Every
source requested by include reports one of completed, stopped_early (with a
reason: budget exhausted, incomplete gh paging window, or a frozen cursor), or
skipped (with a reason: budget exhausted before the source was reached, gh CLI
absent, or a gh failure) — so "this repo has no issues/PRs" is distinguishable from
"issues/PRs were never reached" without parsing warnings[]. history_exhausted is
true only when every requested source completed: it separates "the walk visited
everything" from "the walk stopped before the end", a distinction done's
budget-cursor semantics do not carry.
gh_available is true only after the probe explicitly targets and returns the
owner/repo derived from the canonical source or configured origin; every list call
pins that value with --repo. Argument-less repository selection is never used, so an
alternate remote selected by gh repo set-default cannot redirect ingestion. It is false
for an absent, unauthenticated, or repository-incompatible gh, and null
when neither issues nor pull requests were requested. A failed probe marks
each requested remote source skipped and does not expose gh stderr.
Every successful response also carries receipt_id, the UUID of a durable
schema-v2 audit event whose payload.result is the exact complete response
and whose target is project_id. The runtime appends this receipt before
returning. git.digest is AlwaysVerbose, so omitted/default MCP presentation
still returns the full UUID and exact stored result. If persistence cannot be
confirmed, the call returns git_digest_receipt_persist_failed and warns that
writes may already have committed instead of returning an unqualified success. If malformed handler
output prevents receipt construction, the runtime still appends one generic
Error audit when the gate audit and event store are available.
For response-loss recovery, record request_started_at_us before dispatch. One recovery
attempt freezes since=request_started_at_us.saturating_sub(1) and
until=recovery_query_time_us + 1; event-list bounds are strict
created_at > since AND created_at < until. Query with top-level
presentation="verbose" and otherwise-identical filters at offsets 0, 1000, 2000, …:
request(
presentation="verbose",
ops="list(namespace=\"<original namespace>\", kind=\"event\", event_kind=\"audit\", verb=\"git.digest\", since=<since>, until=<frozen until>, limit=1000, offset=<offset>)"
)
Advance by the returned row count and stop only on a page shorter than 1000. The frozen
upper bound prevents newly completed receipts from shifting newest-first offsets. Match
payload.result.project_id and, when available, payload.resource.request_id; the exact
report is payload.result. Explicit namespace constrains this multi-record discovery to
the digest's attribution namespace. Under AllowAllGate, get(id=<event id>) remains
namespace-agnostic per ADR-007; repeating namespace can provide Gate/routing context but
does not make the by-ID storage lookup namespace-filtered. A namespace-less list uses
the caller's configured visible-namespace scope, not an isolation guarantee.
request_id groups an entire request, not an individual operation. Batch and chain
members therefore share it. Enumerate all matching receipt rows and treat each event's
receipt_id plus payload.result as the operation-unique recovery record. If one request
contains multiple digests for the same project, event order does not identify their input
positions; inspect every result, or issue one digest per request when one-to-one mapping is
required. If the client timed out before the daemon finished,
the receipt appears only when that pass completes; restart at offset zero with a newly
frozen until on a later attempt rather than
treating temporary absence as proof that nothing committed.
There is no 300-second git.digest or daemon-side dispatch deadline. The
observed 300,000 ms bound is the MCP client's request default, so large
max_items values can outlive a particular caller's wait while the daemon
continues the pass. The durable receipt is the recovery contract; the item
bound is not silently clamped to a transport-specific duration.
Reads the stored ingest cursor and checkpoint for a project and source kind in one
snapshot. Values are exact opaque strings, not a completion receipt or a guarantee of
resumability; oversized values are explicitly omitted. No ingest, remote access, or cursor
writes (ADR-088 Amendment 1).
Thin write verbs that shell to system git (std::process::Command::args, no shell
interpolation). Branch/ref names, remotes, messages, and authors are validated before they
enter fixed argv shapes. Commit paths are bounded, repository-relative, traversal-free,
and internally converted to Git literal pathspecs, so characters such as *, ?, brackets,
Unicode, and caller text such as :(top) remain literal filename text. force on
git.push is always rejected when true — no policy or argument combination authorizes a
force-push through this surface.
The handler-level [git_write] allowlist is mandatory and independent of Gate policy
(ADR-018). With no [[git_write.allowed]] entries, all four write verbs deny every request,
including under AllowAllGate. Repository paths are compared after canonicalization, so an
entry names exactly one real repository; branch patterns are exact names or a glob containing
at most one * wildcard.
[[git_write.allowed]]
repo = "/abs/path/repo"
branches = ["main", "feat/*", "release-*"]| Verb | Param | Type | Required | Notes |
|---|---|---|---|---|
git.commit |
repo |
string | yes | Absolute local path to a git repository (must contain a .git entry). |
message |
string | yes | Commit message, passed as a single -m argument value. |
|
paths |
array<string> | no | Relative paths to stage and scope the commit to. Absent commits everything currently staged/modified in tracked files (git commit -a) — never auto-adds new untracked files. |
|
author |
string | no | Override the commit author, e.g. "Name <email>". |
|
git.branch |
repo |
string | yes | Same as above. |
name |
string | yes | New branch name. | |
from |
string | no | Ref or SHA to branch from. Absent uses the repo's current HEAD. | |
git.push |
repo |
string | yes | Same as above. |
branch |
string | yes | Branch to push. | |
remote |
string | no | Remote to push to (default origin). |
|
force |
bool | no | Always rejected when true (ADR-108 hard rule 1) — present only so an explicit force=true request fails loudly instead of being silently ignored. |
request(ops="git.commit(repo=\"/abs/path/repo\", message=\"fix: thing\") | git.push(repo=\"/abs/path/repo\", branch=\"main\")")
Move an existing branch to an existing commit with an exact expected-head compare. The
[git_write] repository and branch allowlist and Gate policy apply before the repository is touched.
expected is required and must be the exact 40-hex current head; either hex case is accepted.
to must be the full object id of an existing commit; branch names, tags, trees, unknown ids,
and the zero object id refuse. require_fast_forward
defaults to true. The expected-head compare, ancestry check, and ref
compare-and-swap run while holding the same per-repository write lock. The result includes the
observed from, requested to, whether the move was a fast-forward, and receipt_id. An optional
reason is stored with the receipt.
| Param | Type | Required | Notes |
|---|---|---|---|
repo |
string | yes | Absolute local path to an allowlisted git repository. |
branch |
string | yes | Existing branch to move. |
to |
string | yes | Full 40-hex object id of an existing commit. |
expected |
string | yes | Exact 40-hex current branch head. |
require_fast_forward |
boolean | no | Defaults to true. |
reason |
string | no | Operator note stored with the receipt. |
session_id |
string | no | Session label copied to the receipt. |
Deterministic source-code map ingest (ADR-085 Amendment 2, PR #1039). Loaded by default;
set KHIVE_PACKS=kg,code to select only the base and code packs. Also registers the
finding note kind used by the kkernel code-ingest admin CLI's findings.json batch
ingest (not reachable via this MCP verb surface).
Walk a source folder and ingest L1 manifest-declared dependency edges
(Cargo.toml / pyproject.toml / package.json) plus L1.5 regex-based import-scan
module and project edges, into a dedicated map database — never the shared production
graph. A folder with no governing manifest anywhere above its source files still
ingests, using the basename of the ingested folder as its source_project identity.
Idempotent: entity and edge ids are uuid5-derived from identity, so re-ingesting the
same path upserts rather than duplicates, and a synchronous re-resolve pass
materializes edges for any import that only resolves once a later-scanned file's module
becomes known.
| Param | Type | Required | Notes |
|---|---|---|---|
path |
string | yes | Folder to ingest — a monorepo subtree (a single crate/package) is first-class, not a special case of whole-repo ingest. |
db |
string | no | Target map database path. Defaults to <path>/.khive/code-map.db. The shared production database — its default $HOME/.khive/khive.db location and the calling server's actual configured database — is always rejected, with no override. |
languages |
array<string> | no | Restrict ingest to a subset of rust | python | typescript. Omission accepts all three; the success report lists only languages observed under path. |
tiers |
array<string> | no | Select any of l1 | l1.5 | l2. Defaults to L1 and L1.5; L2 is opt-in and currently scans Rust sources only. |
request(ops="code.ingest(path=\"/repo/crates/my-crate\")")
The argument object is closed: unknown names are rejected before filesystem or database access.
The success report's sorted languages array describes languages observed by a selected tier,
rather than echoing the caller's filter. It also includes fts_indexed, the number of entity
documents written to the map's full-text index. Entity and FTS writes are a single success
postcondition for this verb: an FTS failure makes the ingest fail rather than returning a
structurally populated but unsearchable map.
The map database uses the ordinary khive schema. To explore it with the generic KG read verbs, select it as a backend in a dedicated config:
[[backends]]
name = "main"
kind = "sqlite"
path = "/absolute/path/to/code-map.db"kkernel exec --config /absolute/path/to/code-map.toml \
'search(kind="entity", query="my-crate")'
kkernel exec --config /absolute/path/to/code-map.toml \
'resolve(refs=["my-crate"])'Use --config without --db for this read path. With [[backends]] configured, a conflicting
concrete --db override is refused; :memory: remains an explicit ephemeral override, and a path
that canonically matches the declared main backend is normalized as a no-op. A warning that a
daemon has a different configuration and local fallback is required is expected and prevents
accidentally serving the production database. kkernel code-audit is the distinct policy-driven
reporting surface over a code-map database.
Content-addressed binary object storage and sequential uploads (ADR-111, ADR-173). Optional; load with
KHIVE_PACKS=kg,blob. Registers no note or entity kinds. A normal file-backed boot
installs a default FsBlobStore rooted beside the database file even with no
[storage.blob] section in khive.toml and no KHIVE_BLOB_ROOT set; the verbs stay
unconfigured (erroring until a backend is installed) only when the server boots against
an in-memory backend, which has no directory to default a root beside.
Staged uploads currently use FsBlobStore; S3 supports the existing whole-object
operations but returns Unsupported when new staging is required. The known-reference
shortcut in blob.begin can return an existing object without staging. blob.put and
all four upload verbs refuse on a read-only runtime.
Store bytes (base64) in the content-addressed blob store; returns the BLAKE3
ContentRef. Idempotent: identical content returns the same ref without a re-write.
| Param | Type | Required | Notes |
|---|---|---|---|
bytes |
string | yes | Base64-encoded object content. Decoded size is capped at 64 MiB per call (ADR-111's v1 object ceiling). |
Read an object back by content_ref, base64-encoded in the response, with an optional
byte range. Metadata preflight rejects an object reported above the 64 MiB ceiling before
hydration; the backend's streaming actual-byte bound remains authoritative when metadata is
stale or false-small. A requested slice that would base64-encode past the daemon's IPC frame
cap is also rejected. Concurrent blob.get hydration is bounded by the runtime's shared
weighted raw-byte admission; range responses still hydrate and verify the complete object
before slicing.
| Param | Type | Required | Notes |
|---|---|---|---|
content_ref |
string | yes | 64-char lowercase-hex BLAKE3 content reference returned by blob.put or blob.commit. |
range |
object | no | {offset, length}, both non-negative integers when present. Applied to the fetched object as a slice, not a streamed range read. |
Report whether an object exists and its size, answered by a single metadata read with no bytes hydrated.
| Param | Type | Required | Notes |
|---|---|---|---|
content_ref |
string | yes | 64-char lowercase-hex BLAKE3 content reference returned by blob.put or blob.commit. |
Begin an upload with a declared total size. A new upload returns
{upload_id, part_limit, next_index}: upload_id is a 32-character lowercase-hex
string, part_limit is the integer maximum decoded bytes per part, and next_index
starts at integer 0. If the supplied reference already exists, return
{content_ref, size} with its stored integer byte length and no upload_id or staging object.
| Param | Type | Required | Notes |
|---|---|---|---|
size |
integer | yes | Non-negative declared byte length, at most 64 MiB (67,108,864 bytes). Zero is allowed. |
content_ref |
string | no | Optional 64-character lowercase-hex BLAKE3 reference. Checked for existence now and against the hash at commit. |
Use the returned part_limit; it currently equals 780,288 bytes, derived from the
smaller of the request-parser and daemon-frame caps with an 8192-byte reserve for
request fields. Upload IDs are capabilities held in process memory, with no actor
ownership restriction or restart recovery. After a daemon restart, begin again.
Each loaded upload manager allows 128 staged uploads in total and 16 per originating
actor by default. KHIVE_BLOB_UPLOAD_MAX_ACTIVE and
KHIVE_BLOB_UPLOAD_MAX_PER_ACTOR accept positive integer overrides; invalid values
warn and use their defaults. Reaching either ceiling refuses blob.begin with
InvalidInput naming that ceiling. Pending creation and uploads awaiting successful
cleanup occupy slots; successful commit or cleanup releases them. Cancelling the
begin request does not cancel admitted creation: its upload remains tracked until
expiry. The existing-reference shortcut uses no slot and remains available when
the ceiling is full. These limits apply per process, not as a shared disk quota.
On Windows and other non-Unix systems, filesystem staging requires trusted local write access to the blob root, its contents and its ancestor directories. The path checks do not prevent a local writer from swapping a junction or reparse point between validation and use. See the filesystem platform limits.
Append one part and return {next_index, received_bytes}, both non-negative integers.
Parts start at index 0 and proceed sequentially.
| Param | Type | Required | Notes |
|---|---|---|---|
upload_id |
string | yes | 32-character lowercase-hex capability returned by blob.begin. |
index |
integer | yes | Non-negative next part index, or the last accepted index for an identical tail retry. |
bytes |
string | yes | Base64-encoded part with decoded length at most part_limit. An empty part is allowed. |
An identical resend of the last accepted part returns the same counters without
appending or refreshing the idle clock. A tail resend with different decoded length
or bytes is refused and aborts the upload. Other out-of-order indices are refused
with InvalidInput without advancing the upload. A next part crossing the declared
total aborts it; a part exceeding only part_limit is refused while preserving the
upload. Invalid base64 is refused before appending.
Unknown or consumed IDs return unknown upload. With no new part for 3600 seconds
after begin or the last accepted new part, blob.put_part and blob.commit discard
the expired upload and return unknown upload. The daemon also sweeps idle staging
every 600 seconds. KHIVE_BLOB_UPLOAD_IDLE_SECS and
KHIVE_BLOB_UPLOAD_SWEEP_INTERVAL_SECS accept positive integer seconds; invalid
values warn and use their defaults. Tail retries do not extend the idle bound.
Publish a complete upload and return {content_ref, size}: a 64-character lowercase-hex
BLAKE3 string and the integer byte length. Success consumes the upload ID, including
when the content already exists; the result has no deduplication flag.
| Param | Type | Required | Notes |
|---|---|---|---|
upload_id |
string | yes | 32-character lowercase-hex capability returned by blob.begin. |
The received length must equal the declared size. An incomplete commit is refused
with InvalidInput and leaves the upload available for further parts. A mismatch
with the optional expected content_ref aborts the upload. Unknown, consumed, or
expired IDs return unknown upload.
Discard staged bytes and invalidate the upload ID. Success returns {aborted: true}.
Unknown or already consumed IDs return unknown upload; abort does not delete a
committed object.
| Param | Type | Required | Notes |
|---|---|---|---|
upload_id |
string | yes | 32-character lowercase-hex capability returned by blob.begin. |
request(ops="blob.put(bytes=\"aGVsbG8=\")")
request(ops="blob.stat(content_ref=\"<64-char-hex>\")")
request(ops="blob.begin(size=5)")
request(ops="blob.put_part(upload_id=\"<32-char-hex>\", index=0, bytes=\"aGVsbG8=\")")
request(ops="blob.commit(upload_id=\"<32-char-hex>\")")
- Getting Started: install and connect an MCP client.
- Knowledge Graph Modeling: entity kinds, edge relations, patterns.
- Memory and Recall: salience, decay, and recall internals.
- Search and Retrieval: FTS, vector, hybrid fusion, reranking.
- GTD Task Management: task lifecycle in depth.
- Prompt Cookbook: ready-to-use verb patterns.
- ADR-016: request DSL
- ADR-002: Closed Edge Ontology
Registry objects are project entities typed tool, skill, plugin or verb, tagged
tool-registry; capabilities are concept entities typed capability joined by implements edges
(ADR-180). Every decision answer carries decision (allow, deny, ask), source (grant,
policy, default) and the row id it came from.
tool.register(name, kind="tool", description, schema, source, side_effect="write", trust="external", capabilities=[], tags=[]).
Creates the object or returns the existing one by name (created: false); capabilities are created when
absent and linked.
tool.ingest(source="khive") registers every loaded verb under khive:<pack> with one capability per
pack; tool.ingest(source="mcp", server, tools=[...]) registers an MCP tools/list payload under
mcp:<server>. Returns registered and existing counts.
tool.suggest(query, limit=10, kind, actor): hybrid search over the registry merged with capability
concepts expanded through implements; each hit carries score, via (capability names) and the
caller's decision.
tool.describe(tool, actor) returns the full object with schema, capabilities and decision;
tool.list(kind, limit=100, offset=0) pages the registry.
tool.check(tool, actor): active grant, then the most specific matching policy (deny over ask over
allow on ties), then the default (allow for read side effects, ask otherwise and for unregistered
names).
tool.request(tool, actor, scope, reason, notify): returns the decision with request_id: null when
already allowed; otherwise inserts a requested row and, when notify names an actor and the comm pack
is loaded, mails it.
tool.grant(id, expires_in_s, note) from requested or denied; tool.deny(id, note) from
requested or granted; tool.revoke(id, note) from granted. Any other transition is refused with the
current status. A requester cannot grant its own request.
Approval of a registered name stores registry_id and definition_digest, pinning
the current source, side_effect, trust, and schema. An active grant must
match both the current registry object and those policy inputs; a stale or partial
pin falls through to policy/default without changing the row's granted status.
Description and capabilities are outside the pin. Requests themselves are unpinned.
An unregistered-name grant has null pins. First matching registration records
invalidated_by_registry_id and invalidated_at on that grant; deleting the
registration does not reactivate it. This also covers existing matching wildcard
registrations when an unpinned approval is made. Deny/revoke retain those markers;
only an explicit approval bound to a live registry object clears them. Schema
upgrades preserve legacy decisions and may add invalidation evidence, never
unapproved pins; deleted registration history unavailable at upgrade cannot be
reconstructed.
tool.requests(status, actor, tool, limit=50) lists grant rows; tool.policy(actor, tool, decision, note)
stores a rule where actor and tool are exact labels, trailing-* prefixes or *;
tool.policies(actor, limit=100) lists rules.
tool.policy_delete(actor, tool, namespace) retires the live policy whose stored actor and tool
labels exactly match the supplied strings, in the request's namespace. actor and tool are required
nonempty strings; namespace is the optional shared request override. A stored pattern such as
actor="svc:*" is matched literally when retiring it, so this call retires only that named rule.
Returns { "ok": true, "policy": {...} } with the retained policy row, its correction history, and
the retirement fields deleted_at and deleted_by. tool.policies continues to expose the retired
row; tool.check excludes it while retaining its normal grant, live-policy, then default precedence. If no live
rule has those exact labels, including on a repeated deletion, the call refuses with a not-found
error. See ADR-180 Amendment 3.