Skip to content

Latest commit

 

History

History
3224 lines (2580 loc) · 235 KB

File metadata and controls

3224 lines (2580 loc) · 235 KB

API Reference

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.

Packs at a glance

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).


DSL syntax

The request tool takes one string argument, ops, in one of four forms.

Single op

request(ops="search(kind=\"entity\", query=\"LoRA\")")

Parallel batch

Up to 100 ops, run with no ordering guarantee between them:

request(ops="[memory.recall(query=\"x\"), memory.remember(content=\"y\")]")

Chain

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".

JSON form

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.

Parser constraints (source: khive-request, ADR-016)

  • MAX_OPS = 100 per request; exceeding it is DslError::TooManyOps.
  • $prev is 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), or link (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 the request tool 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.verb names are supported — a.b.c is DslError::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.

Response envelope

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.


kg pack — 26 verbs

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 — Commissive

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.

get — Assertive

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 — Declaration

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 — Assertive

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 role content; entities no longer store a writable same-named column. created_at/updated_at/deleted_at are 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-level tags field: unlike entities, tags live inside properties.tags. If the note's properties.status is set (e.g. a gtd task's lifecycle status, or a comm message's delivery state), the row's substrate-level status (normally "active") is renamed to lifecycle, and the top-level status is replaced with the properties.status value, so a gtd/comm consumer reads the pack-level status directly off the row instead of digging into properties. When no properties.status is set, status stays the raw substrate value and there is no lifecycle key.
  • 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 supported list kind 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 in crates/khive-pack-kg/src/handlers/proposal.rs). That field set is the presentation="verbose" projection; the default Agent mode applies the same generic reshaping as the other list rows: non-lifecycle null/empty fields are omitted (a null expiry, an empty last_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.

stats — Assertive

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()")

update — Declaration

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)")

delete — Declaration

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.

merge — Declaration

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>\")")

search — Assertive

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 contributes 1 / (k + rank) with k = 10; contributions from every leg that hit the entity are summed, then a flat +0.5 boost is added when the entity's title is an exact case-insensitive match for the query. A single-leg rank-1 hit is 0.0909; an entity hit by both legs at rank 1, with an exact title match, would score 1/11 + 1/11 + 0.5 ≈ 0.682.
  • Note (crates/khive-runtime/src/operations.rs): the same per-leg RRF sum, but with k = 60, is then multiplied by a salience-derived weight, 0.5 + 0.5 * salience (salience defaults to 0.5 when 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.

link — Commissive

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.

neighbors — Assertive

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.

traverse — Assertive

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.

context — Assertive

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 }
}

query — Assertive

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.

propose — Commissive

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\"}}}}]")

review — Declaration

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 — Commissive

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 — Assertive

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>\"])")

whoami — Assertive

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()")

scan — Assertive

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-...\")")

db_diagnostics — Assertive

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()")

verbs — Assertive

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 pack — 7 verbs

GTD task lifecycle over notes (kind="task"). Optional; load with KHIVE_PACKS=kg,gtd.

gtd.assign — Directive

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\")")

gtd.next — Assertive

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)")

gtd.complete — Declaration

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\")")

gtd.tasks — Assertive

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\")")

gtd.transition — Declaration

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\")")

gtd.census — Assertive

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()")

gtd.repair — Declaration

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.


memory pack — 5 verbs

Salience- and decay-weighted memory notes. Optional; load with KHIVE_PACKS=kg,memory.

memory.remember — Commissive

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\")")

memory.recall — Assertive

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)")

memory.feedback — Commissive

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\")")

memory.prune — Commissive

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)")

memory.vacuum — Commissive

Run SQLite VACUUM to reclaim space freed by soft-deleted rows. No params.

request(ops="memory.vacuum()")

brain pack — 16 verbs

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.

brain.event_counts — Assertive

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)')

brain.profiles — Assertive

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\")")

brain.profile — Assertive

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\")")

brain.resolve — Assertive

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.

brain.activate — Commissive

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\")")

brain.deactivate — Commissive

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\")")

brain.archive — Declaration

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\")")

brain.reset — Declaration

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\")")

brain.feedback — Commissive

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\")")

brain.auto_feedback — Commissive

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\")")

brain.mark_turn — Commissive

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\")")

brain.bind — Declaration

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\")")

brain.unbind — Declaration

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.

brain.bindings — Assertive

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.

brain.create_profile — Declaration

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\")")

brain.register_adapter — Declaration

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\")")

comm pack — 10 verbs

Actor-to-actor messaging with threading. Optional; load with KHIVE_PACKS=kg,comm.

comm.send — Commissive

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.

comm.delivered — Assertive

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>\")")

comm.inbox — Assertive

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.

comm.unread — Assertive

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()")

comm.read — Declaration

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.

comm.mark_read — Declaration

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.

comm.reply — Commissive

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.\")")

comm.thread — Assertive

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\"])")

comm.probe — Assertive

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)")

comm.health — Assertive

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()")

schedule pack — 4 verbs

Time-triggered reminders and deferred verb dispatch. Optional; load with KHIVE_PACKS=kg,schedule. Add comm to create reminders.

schedule.remind — Commissive

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.schedule — Commissive

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\")")

schedule.agenda — Assertive

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)")

schedule.cancel — Declaration

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>\")")

knowledge pack — 19 verbs

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.

knowledge.upsert_atoms — Commissive

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...\"}]}}]")

knowledge.upsert_domains — Commissive

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\"}]}}]")

knowledge.get — Assertive

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)")

knowledge.list — Assertive

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.

knowledge.delete_atoms — Commissive

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\"])")

knowledge.stats — Assertive

Corpus statistics: atom count, domain count, coverage. No params.

request(ops="knowledge.stats()")

knowledge.index — Commissive

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)")

knowledge.fold — Assertive

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}}]")

knowledge.search — Assertive

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)")

knowledge.suggest — Assertive

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\")")

knowledge.compose — Assertive

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\"])")

knowledge.edit — Commissive

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...\"}]}}]")

knowledge.import — Commissive

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.

knowledge.challenge — Commissive

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\")")

knowledge.adjudicate — Commissive

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\")")

knowledge.learn — Commissive

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\")")

knowledge.cite — Commissive

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>\")")

knowledge.topic — Assertive

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\")")

knowledge.feedback — Commissive

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.


session pack — 7 verbs

Cross-provider agent-session continuity records. Optional; load with KHIVE_PACKS=kg,session.

session.store — Directive

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\")")

session.list — Assertive

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)")

session.resume — Assertive

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>\")")

session.export — Assertive

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\")")

session.search — Assertive (dependency gated)

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.

session.stats — Assertive

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.

session.vacuum — Commissive

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.


exec tree manifests

The exec pack uses immutable khive-tree/v1 manifests, also consumed by Git tree operations. See ADR-181 for run and sandbox semantics.

exec.tree, exec.tree_get, exec.tree_put, exec.tree_diff

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.


git pack — 17 verbs

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).

git.digest — Commissive

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.

git.ingest_cursor — Assertive

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).

git.commit / git.branch / git.update_ref / git.push — Commissive (ADR-108)

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\")")

git.update_ref — Commissive

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.

code pack — 1 verb

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).

code.ingest — Commissive

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.


blob pack — 7 verbs

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.

blob.put — Commissive

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).

blob.get — Assertive

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.

blob.stat — Assertive

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.

blob.begin — Declaration

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.

blob.put_part — Declaration

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.

blob.commit — Declaration

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.

blob.abort — Declaration

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>\")")

Further reading

tool pack — 14 verbs

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 — Commissive

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 — Commissive

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 — Assertive

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.list — Assertive

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 — Assertive

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 — Directive

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 / tool.deny / tool.revoke — Declaration

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 / tool.policy / tool.policies

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 — Commissive

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.