Skip to content
MonkeyLLMDocs

Build

Primitives

Ten calls read and write a forest, and two composites collapse the loop into one round trip. Every response fits a declared token budget, every truncation is stated in the payload, and the three calls that write are the three that commit to git.

On this page(26)

The twelve calls

Seven primitives read, three write, and two composites orchestrate the others. Nothing else touches a forest.

The twelve primitives at a glance
CallReach for it whenBudgetCapability
locateYou have a question and no id. Ranked entry points over curated metadata.800read
lookYou have an id and want to know what it holds before paying for the body.500read
moveYou are standing on a node and want its neighbours along typed edges.600read
pickThe summary says this is the one. Take the body, or one section of it.4000read
scanYou want a filtered list of a branch, by metadata, without opening files.800read
sniffThe question carries an exact term summaries never lift: a code, a number, a name.800read
queryThe answer is an aggregate over rows, not a sentence in prose.2000query
plantSomething new is worth keeping. Creates a node, indexes it, commits it.tinywrite
graftAn existing node is wrong, incomplete, or missing a link you just learned.tinywrite
tendRows enter, change or leave a dataset payload.tinytend
harvestYou want evidence in one call and will reason over it with your own model.4000read
answerYou want prose back, read by the model bound to the forest, with its evidence attached.modelread

Budgets are output tokens, enforced per response. The three write primitives return a handful of fields and have no declared budget, so the table says tiny rather than inventing a number. answer returns a model reply whose size is bounded by reply_tokens, not by a Part C budget.

Three calls write to git

plant, graft and tend are the only calls that touch the repository. Each one is atomic: the file, the parent index, the catalog and the commit either all land or none do. Read primitives never commit. They do append a trace event to the disposable _derived/ layer, which is not versioned and is rebuilt by vine reindex; heat is deposited along the winning trail when a hunt closes, not on every call.

The shared contract

Everything below applies to all twelve calls, so the individual references do not repeat it.

Budgets and truncation

Every response fits its declared token budget. When it does not, the engine drops whole items from the tail of the response list and sets "truncated": true. It never slices a body, never trims a row in half, and never cuts silently.

Two rules follow, and they matter more than the numbers:

  • Truncation is never absence. truncated: true means the response was cut by a budget, not that nothing else matched. An agent that reports a truncated result as the complete set is reporting a display bound as a count.
  • The way out is in the payload. Where a budget bites hard, the response carries a hint saying what to do instead: narrow the projection, name a section, aggregate.

Token counts are a deterministic estimate, roughly four characters per token over the serialized JSON, tuned for English and Portuguese markdown. The contract is the enforcement and the explicit marker, not the tokenizer.

Calling a primitive

The same call reaches a forest three ways. The parameter names below are identical in all three, because they are the Python signature.

from monkeyllm import Vine

vine = Vine("./brain")                      # writable: takes .vine.lock
vine.locate("exchange policy deadlines", k=3)
vine.pick("sales/exchange-policy", section="Deadlines")

On the REST surface every primitive is one route, POST /v1/forests/{forest}/{primitive}, whose JSON body is the argument object. The forest is in the path, so it is never a body parameter there.

The forest parameter

The engine MCP server takes an optional forest on every tool. Serving a single forest (vine serve --forest DIR) it may be omitted; serving a registry (vine serve --root DIR) it is required on every call except forests(), and a call without it answers E_SCHEMA listing the available ids. The Station MCP surface makes forest the first argument of every tool. It is omitted from the parameter tables below to keep them about the call itself.

Surfaces expose slightly different parameters

The Python signature is the widest. The engine MCP tools expose all of it except the frontier arguments (gauntlet, toward); the Station REST surface dispatches straight onto the scoped Python signature, so those two are reachable there; the Station MCP surface is the narrowest, and drops type_filter from sniff and fields from scan. Each table below marks the arguments that are not universal.

The error envelope

Expected failures come back as data, not as exceptions or transport errors: a JSON object with one error key.

json
{
  "error": {
    "code": "E_QUERY_INVALID",
    "message": "SQL error: no such column: total_value",
    "hint": "Columns: orders(id, customer, region, total_invoice, closed_at)."
  }
}
Error codes
CodeMeansHTTP
E_NOT_FOUNDNo such node, section, or forest. On a host, also what an out-of-scope node answers, byte for byte.404
E_SCHEMAThe request is malformed: bad argument, invalid node spec, unknown rel.400
E_FRONTMATTERA node passport failed validation, most often the summary rules.400
E_READONLYA write against a read-only Vine, or an immutable frontmatter field.403
E_QUERY_FORBIDDENThe SQL guard refused: not a dataset, several statements, wrong leading keyword, forbidden keyword, UPDATE or DELETE without WHERE.403
E_QUERY_INVALIDThe statement passed the guard and SQLite refused it: unknown table or column, syntax error.400
E_TIMEOUTA dataset statement exceeded the 2 second deadline.504
E_LOCKEDAnother writer holds the forest lock.409
E_FORBIDDENHost only: the key lacks the capability the call requires.403

The split between E_QUERY_FORBIDDEN and E_QUERY_INVALID is worth internalizing: 403 says the principal may not, 400 says the request was wrong. Retrying a 403 is pointless; retrying a 400 with a corrected statement is exactly right.

Read primitives

Seven calls, in the order a hunt normally uses them. None of them commits, all of them are traced, and all of them deposit heat on the nodes they touch.

locate

The helicopter. Turns a question into ranked entry points, so an agent starts near the target instead of at the trunk. Reach for it when you have a question and no id, and when the question is conceptual rather than built around one exact string.

It searches curated metadata only: title, aliases, tags and summary, through SQLite FTS5. It deliberately does not index bodies, which is what sniff is for. The index covers both leaves (bananas) and regions (branches), so a broad question can land on a whole area with scope: "branches" and navigate down from there.

POST/v1/forests/{forest}/locateread

Ranked entry points for a free-text query.

Parameters

NameTypeRequiredDefaultDescription
querystringrequirednoneFree text. Matched against title, aliases, tags and summary.
kintoptional5Maximum results returned. Candidates are pulled at max(k * 5, 25) before filtering, so scope and type_filter still have something to choose from.
scope"all" | "branches" | "bananas"optional"all"Restricts results by kind. branches gives landing zones for broad questions, bananas gives leaves for pointed ones.
type_filterstring | nulloptionalnullKeeps only one node type: note, document, dataset, entity, concept, event, media or branch.

Returns

results is a list ordered by score descending. Each item carries id, kind (banana or branch), type, title, summary, trail (the ancestor index ids), score and heat. A branch result also carries coverage, the human count of what lives under it.

Score is strength * (1 + alpha * heat) with alpha defaulting to 0.3, where strength is the normalized lexical rank (or the RRF fusion of lexical and vector ranks when a Canopy index and a matching query embedder are both present). Setting alpha to 0 on the Vine turns pheromone off.

Request

{"tool": "locate",
 "args": {"query": "exchange policy deadlines", "k": 3, "scope": "all"}}

Response

json
{
  "results": [
    {
      "id": "sales/_index",
      "kind": "branch",
      "type": "branch",
      "title": "Sales",
      "summary": "Commercial policy, price lists and the 2026 order ledger.",
      "trail": ["_index"],
      "score": 0.9724,
      "heat": 0.4,
      "coverage": "23 bananas, 4 sub-branches."
    },
    {
      "id": "sales/exchange-policy",
      "kind": "banana",
      "type": "note",
      "title": "Exchange policy",
      "summary": "Return and exchange windows per channel, with invoice rules.",
      "trail": ["_index", "sales/_index"],
      "score": 0.8241,
      "heat": 0.31
    }
  ],
  "truncated": false
}

Budget and truncation

800 tokens. truncated is true when more than k candidates matched, and stays true if the budget then had to drop items off the tail. A truncated locate means there were more entry points, so narrow the query rather than concluding the forest is small.

look

The central operation. A digest of one node: what it is, what it is about, what it connects to, and how expensive its body would be. Reach for it after every hop, before deciding whether to pay for pick.

The hard 500 token budget is the point. Reading a digest costs a tenth of reading a body, so an agent can afford to look at five candidates and pick one.

POST/v1/forests/{forest}/lookread

The bounded digest of a single node.

Parameters

NameTypeRequiredDefaultDescription
idstringrequirednoneNode id, for example sales/exchange-policy or sales/_index. Unknown id answers E_NOT_FOUND.
fieldslist[string] | nulloptionalnullReturn only these fields, plus id, which is always present. look(id, fields=["summary"]) costs roughly 70 tokens against 400 for the full digest, which is what makes a multi-node sweep affordable. On a dataset it also decides which payload reads happen at all.
gauntletbool | nulloptionalnullFrontier ordering. false disables it for this call; null leaves it to whether the goal and the vector layer exist. Not exposed on either MCP tool.
towardstring | nulloptionalnullAn explicit goal text for frontier ordering, instead of the query the last locate remembered. Not exposed on either MCP tool.

Returns

Always: id, type, title, summary, tags, confidence, updated, edges_out, edges_in and stats. Then it varies by kind:

  • A banana carries outline, the list of its section headers.
  • A branch carries children (each with id and a truncated summary), plus cross_trails and coverage when the index declares them.
  • A dataset also carries query_manual (tables with their columns, and up to three example queries), sample_rows (at most 3), and notes, the section a person wrote about how to read the data.

Edges are capped at 12 each and ordered by heat descending, with stats.degree reporting the real total. target_summary is truncated to 25 tokens, because it is a neighbour scent and not a second digest. stats.body_tokens is what lets an agent price a pick before making it.

Request

{"tool": "look", "args": {"id": "sales/exchange-policy"}}

// cheap sweep: one field, for several nodes in a row
{"tool": "look", "args": {"id": "sales/price-list", "fields": ["summary"]}}

Response

json
{
  "id": "sales/exchange-policy",
  "type": "note",
  "title": "Exchange policy",
  "summary": "Return and exchange windows per channel, with invoice rules.",
  "tags": ["sales", "policy"],
  "confidence": 1.0,
  "updated": "2026-05-04",
  "edges_out": [
    {"rel": "part-of", "target": "sales/_index",
     "target_summary": "Commercial policy, price lists and the 2026 order ledger."}
  ],
  "edges_in": [
    {"rel": "mentions", "source": "people/ana-ribeiro"}
  ],
  "outline": ["Channels", "Deadlines", "Invoice rules"],
  "stats": {"body_tokens": 612, "degree": 7, "heat": 0.31}
}

A dataset digest

A dataset is the one node type whose facts no text primitive can see, so its digest carries the map and the meaning together. Read it before writing SQL.

jsonlook on a dataset node
{
  "id": "sales/orders-2026",
  "type": "dataset",
  "title": "Orders 2026",
  "summary": "Every closed order of 2026, one row per order, with region and value.",
  "tags": ["sales", "ledger"],
  "confidence": 1.0,
  "updated": "2026-06-30",
  "edges_out": [],
  "edges_in": [],
  "outline": ["Query manual", "Sample rows", "Notes"],
  "query_manual": {
    "tables": {"orders": ["id", "customer", "region", "total_invoice", "closed_at"]},
    "example_queries": ["SELECT * FROM orders LIMIT 5", "SELECT COUNT(*) FROM orders"]
  },
  "sample_rows": {
    "columns": ["id", "customer", "region", "total_invoice", "closed_at"],
    "rows": [["1045", "Acme", "Southeast", "USD 54.607,56", "2026-02-11"]]
  },
  "notes": "total_invoice is TEXT holding a formatted amount, so SUM() returns 0. Cast it, or use the value_cents column.",
  "stats": {"body_tokens": 388, "degree": 0, "heat": 0.52}
}

Notes are the operator speaking

notes is the node's ## Notes section: what a person taught about the data that no schema can state, such as which column is in which currency, or that an amount is stored as text and will silently sum to zero. It is never written by ingestion or by curation, survives every sync, and rides in look precisely because the path to a dataset is look then query. It is bounded at 200 tokens inside the 500 token digest and clipped out loud.

Budget and truncation

500 tokens. Lists (edges_in, edges_out, children) shed items from the tail first and set truncated. If the digest still does not fit, sample_rows is dropped before anything else, because three generated rows are cheaper to lose than a sentence a person wrote.

Frontier ordering (the Gauntlet)

look, move and scan accept gauntlet and toward. When a Canopy vector index exists and its model matches the query embedder, the frontier is ordered by proximity to the goal (the query the last locate remembered, or the toward text you pass), with heat breaking near ties. The ordering happens before the budget cut, which is the whole point. When any precondition is missing the call behaves exactly as it did without the feature, and the response simply carries no frontier field.

move

Structural navigation. The neighbours of a node along typed edges, or the physical children of a branch. Reach for it when a digest showed an edge worth following, or when you want the contents of a region without filtering.

POST/v1/forests/{forest}/moveread

Neighbours of a node along typed edges.

Parameters

NameTypeRequiredDefaultDescription
idstringrequirednoneThe node you are standing on.
relstring | nulloptionalnullKeep only this edge type. The special value "children" is sugar for a branch's physical children and ignores direction. Valid rels are the dialect's: part-of, related-to, mentioned-in, author, compared-with, derived-from, same-as, discovered-shortcut, succeeds and their derived inverses.
direction"out" | "in" | "both"optional"out"Follow edges the node declares, edges that point at it, or both.
gauntletbool | nulloptionalnullFrontier ordering, as on look. Not exposed on either MCP tool.
towardstring | nulloptionalnullExplicit goal text for frontier ordering. Not exposed on either MCP tool.

Returns

neighbors, ordered by heat descending, each with id, rel, direction, type, summary and heat. Incoming edges are reported under their inverse name, so a node linked by mentioned-in appears as mentions from the other side.

Request

{"tool": "move", "args": {"id": "sales/_index", "rel": "children"}}

// typed edges instead, in both directions
{"tool": "move", "args": {"id": "sales/exchange-policy", "direction": "both"}}

Response

json
{
  "neighbors": [
    {
      "id": "sales/orders-2026",
      "rel": "children",
      "direction": "out",
      "type": "dataset",
      "summary": "Every closed order of 2026, one row per order, with region and value.",
      "heat": 0.52
    },
    {
      "id": "sales/exchange-policy",
      "rel": "children",
      "direction": "out",
      "type": "note",
      "summary": "Return and exchange windows per channel, with invoice rules.",
      "heat": 0.31
    }
  ],
  "truncated": false
}

Budget and truncation

600 tokens. Unlike locate there is no k: every matching neighbour is collected, then the tail is dropped until the response fits, setting truncated. A hub node with dozens of edges will therefore truncate, and scan with a filter is the better call there.

pick

Harvest the banana. The full markdown body, or one named section of it. Reach for it last, when a digest or a sniff snippet has already said this is the node and, ideally, which section.

POST/v1/forests/{forest}/pickread

The body of a node, or one of its sections.

Parameters

NameTypeRequiredDefaultDescription
idstringrequirednoneThe node whose body you want.
sectionstring | nulloptionalnullA header from the node's outline. Matched case-insensitively, exact match first and then by prefix. The returned body includes the header line and runs to the next header of the same or higher level. A header that does not exist answers E_NOT_FOUND with the available sections in the hint.

Returns

With a section: id, title, section, body, body_tokens and truncated: false. Without one, the same minus section.

Request

{"tool": "pick",
 "args": {"id": "sales/exchange-policy", "section": "Deadlines"}}

Response

json
{
  "id": "sales/exchange-policy",
  "title": "Exchange policy",
  "section": "Deadlines",
  "body": "## Deadlines\n\nRetail returns close 30 days after the invoice date...",
  "body_tokens": 146,
  "truncated": false
}

Budget and truncation

4000 tokens, and the behaviour here is different from every other primitive: a body over the limit is not partially returned. You get the outline, the real body_tokens, truncated: true and a hint telling you to name a section. Nothing is silently cut, and the response is a map to what you asked for.

jsonpick on a body over the limit
{
  "id": "contracts/master-agreement",
  "title": "Master agreement 2026",
  "outline": ["Parties", "Scope", "Payment terms", "Termination", "Annexes"],
  "body_tokens": 11840,
  "truncated": true,
  "hint": "Body exceeds 4000 tokens. Use section=<header> to harvest one section."
}

A section read has no separate cap: it returns the section whatever its size, which is the escape hatch the hint points at. Documents whose body lives outside the .md file (converted or referenced content) are resolved transparently, and an unreachable source answers E_NOT_FOUND while the map primitives keep working.

scan

Metadata query over a branch. Filters children by frontmatter, served entirely from the catalog, with no file opened. Reach for it when you know the region and want a subset of it by property: the sales datasets updated this quarter, the events of last month, the nodes below a confidence floor.

POST/v1/forests/{forest}/scanread

Filter a branch's children by metadata, without opening files.

Parameters

NameTypeRequiredDefaultDescription
parent_idstringrequirednoneThe branch to scan, for example sales/_index or the root _index.
filterobject | nulloptionalnullMetadata predicates, all combined with AND. See the table below. An unknown key answers E_SCHEMA.
fieldslist[string] | nulloptional["id", "type", "summary"]Which columns to return. id is always included. Not exposed on the Station MCP surface.
recursivebooloptionalfalseDescend the whole subtree instead of the branch direct children.
limitintoptional50Maximum nodes returned. Clamped to the range 1 to 50.
gauntletbool | nulloptionalnullFrontier ordering, as on look. Not exposed on either MCP tool.
towardstring | nulloptionalnullExplicit goal text for frontier ordering. Not exposed on either MCP tool.

Filter keys

NameTypeRequiredDefaultDescription
tags_anylist[string]optionalnoneNode carries at least one of these tags.
updated_afterstring (ISO date)optionalnoneNode updated strictly after this date.
updated_beforestring (ISO date)optionalnoneNode updated strictly before this date.
created_afterstring (ISO date)optionalnoneNode created strictly after this date.
min_confidencefloatoptionalnoneNode confidence at or above this value.
<any catalog column>valueoptionalnoneExact equality on a stored column: type, kind, source, entity_kind, payload_type, parent, title, confidence and the rest of the node row.

Returns

nodes, ordered by heat descending, each holding id plus the requested fields. JSON-encoded columns (tags, aliases, trail, outline) come back parsed, and heat is computed rather than stored.

Request

{"tool": "scan",
 "args": {"parent_id": "_index",
          "filter": {"type": "dataset", "updated_after": "2026-03-01",
                     "tags_any": ["sales"]},
          "fields": ["id", "summary", "payload_type"],
          "recursive": true, "limit": 20}}

Response

json
{
  "nodes": [
    {
      "id": "sales/orders-2026",
      "summary": "Every closed order of 2026, one row per order, with region and value.",
      "payload_type": "sqlite"
    },
    {
      "id": "sales/pipeline/prospects-q2",
      "summary": "Prospects worked in Q2 2026, with owner, stage and expected value.",
      "payload_type": "sqlite"
    }
  ],
  "truncated": false
}

Budget and truncation

800 tokens. truncated is true when more nodes matched than limit, and again when the budget dropped items. Asking for fewer fields is the cheapest way to fit more nodes: the canonical use is one call of roughly 200 tokens instead of descending a hierarchy opening indexes.

sniff

The tracker. Literal substring search over node bodies, returning node, section, line and snippet. Reach for it when the question contains something exact that nobody would have lifted into a summary: an invoice number, an error code, a product SKU, a proper name.

The split with locate is normative and worth remembering: locate reads curated metadata and never bodies; sniff reads bodies and never curated metadata, except to display what it found.

POST/v1/forests/{forest}/sniffread

Literal search inside node bodies, with the matching lines.

Parameters

NameTypeRequiredDefaultDescription
termsstring | list[string]requirednone1 to 8 literal terms; a single string is promoted to a one item list. Matching is substring, case-insensitive and diacritic-insensitive. A term containing a space is an exact phrase. Regex is not accepted. A term shorter than 2 characters after normalization answers E_SCHEMA, and so does a list longer than 8.
scopestring | nulloptionalnullAny node id. A branch (sales or sales/_index, both accepted) restricts the search to that physical subtree; a leaf restricts it to that single body, which is grep-within-a-node. Must be a string, not a list. Unknown id answers E_NOT_FOUND.
kintoptional5Maximum nodes in the result. Clamped to the range 1 to 20.
type_filterstring | nulloptionalnullKeep only one node type, as on locate. Not exposed on the Station MCP surface.

Returns

results plus scanned_nodes, the number of bodies actually read. Each result carries id, type, title, trail, score, heat, match_count (the real total), truncated_matches and matches, at most 3 per node, each with section, line and snippet.

Ranking is strength * (1 + alpha * heat) where strength is matched terms over requested terms, tie-broken by match_count. So a node hitting three of your four terms outranks one hitting a single term forty times: AND-preferred, OR-tolerant. The snippet is a window of about 25 tokens centred on the leftmost hit in the line.

Request

{"tool": "sniff",
 "args": {"terms": ["NF-4412", "exchange"], "scope": "sales", "k": 5}}

Response

json
{
  "results": [
    {
      "id": "sales/exchange-policy",
      "type": "note",
      "title": "Exchange policy",
      "trail": ["_index", "sales/_index"],
      "score": 1.2065,
      "heat": 0.31,
      "match_count": 4,
      "truncated_matches": true,
      "matches": [
        {"section": "Deadlines", "line": 23,
         "snippet": "return with invoice NF-4412 within 30 days of receipt"},
        {"section": "Invoice rules", "line": 41,
         "snippet": "an exchange reissues the invoice under the same series"},
        {"section": "Channels", "line": 12,
         "snippet": "retail exchange is handled at the counter"}
      ]
    }
  ],
  "scanned_nodes": 82,
  "truncated": false
}

Budget and truncation

800 tokens, and this primitive truncates in two independent places, so read both flags:

  • truncated_matches per node means that node had more than 3 matching lines. match_count tells you how many.
  • truncated at the top level means whole nodes were dropped from the tail, either past k or by the budget.

For a scoped principal on the Station, scanned_nodes is replaced by the number of results that principal may actually see: the engine count would otherwise be a forest-size oracle.

query

Read-only SQL over a dataset payload. Reach for it when the answer is an aggregate, a filter or a join over rows. Prose about a dataset lives in its body; the numbers live only here.

POST/v1/forests/{forest}/queryquery

A single read-only statement against a dataset node SQLite payload.

Parameters

NameTypeRequiredDefaultDescription
idstringrequirednoneA node with type: dataset and payload_type: sqlite. Anything else answers E_QUERY_FORBIDDEN.
sqlstringrequirednoneExactly one statement, starting with SELECT or WITH. A trailing semicolon is stripped; an internal one is refused. ATTACH, DETACH, PRAGMA, INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, VACUUM and REINDEX are forbidden anywhere in the text.

Returns

A columnar payload, which is roughly 40 percent cheaper than repeating keys per row: columns, rows (a list of lists), row_count, limited and elapsed_ms. The connection is opened read-only, a LIMIT 200 is injected when the statement has none, and the statement is killed at 2 seconds with E_TIMEOUT.

Request

{"tool": "query",
 "args": {"id": "sales/orders-2026",
          "sql": "SELECT region, COUNT(*) AS orders FROM orders GROUP BY region"}}

Response

json
{
  "columns": ["region", "orders"],
  "rows": [["Southeast", 812], ["South", 344], ["Northeast", 190]],
  "row_count": 3,
  "limited": false,
  "elapsed_ms": 3.11
}

Budget and truncation

2000 tokens, deliberately below pick: a body is read once, while a query result enters a loop that carries it forward turn after turn. Whole rows are dropped from the tail, never sliced, and columns is never dropped, because it is the only part of the response that says how to ask again.

limited and truncated are different failures

limited: true means the injected LIMIT 200 was reached: your filter matched more rows than were returned, so narrow the WHERE. truncated: true means the token budget dropped rows the query did return, so narrow the projection or aggregate. Both can be true at once. Neither means the data is absent.

jsonA wide SELECT * against a 141 column table
{
  "columns": ["id", "customer", "region", "total_invoice", "closed_at", "..."],
  "rows": [["1045", "Acme", "Southeast", "USD 54.607,56", "2026-02-11", "..."]],
  "row_count": 2,
  "limited": true,
  "elapsed_ms": 41.7,
  "truncated": true,
  "hint": "Showing 2 of 200 rows. The other 198 matched your query and exist: they were dropped by the 2000-token response budget, not by your filter, do NOT report these 2 as the complete result. This statement returns 141 column(s); ask again with fewer columns to fit more rows, or aggregate."
}

When every row is dropped, the hint carries the per-row cost as well, so the next statement can be sized rather than guessed. Aggregates are unaffected by construction: SELECT SUM(x) is one short row. A name that does not exist answers E_QUERY_INVALID with the dataset's real table names, or the columns of its tables, in the hint.

Write primitives

Three calls, and they are the three that commit. Each is atomic: file, parent index, catalog and git commit either all succeed or the filesystem is restored. Writes serialize through a single mutex, reads never block, and a .vine.lock at the forest root stops a second writer with E_LOCKED. A Vine opened read-only refuses all three with E_READONLY.

plant

Create a node. Reach for it when an agent learned something worth keeping, or when a document needs a structured twin. It writes the file, inserts the entry into the parent index, commits, and marks the node for re-embedding.

POST/v1/forests/{forest}/plantwrite

Create a node: frontmatter, body, parent entry and commit, atomically.

Parameters

NameTypeRequiredDefaultDescription
nodeobject (NodeSpec)requirednoneThe full node specification. Fields below.

NodeSpec fields

NameTypeRequiredDefaultDescription
idstringrequirednonePath-shaped and unique, for example clients/prospecting-2026. Immutable once planted. It must live directly under parent, or the call answers E_SCHEMA naming the parent it expected.
typestringrequirednoneOne of note, document, dataset, entity, concept, event, media, branch. An unknown type answers E_SCHEMA.
titlestringrequirednoneHuman title. Becomes the body H1 when the body has none.
summarystringrequirednoneThe scent, and the most load-bearing field in the system: at most 60 tokens, never empty, and refused when it opens with a boilerplate anti-pattern instead of naming what the node is. Violations answer E_FRONTMATTER.
parentstringrequirednoneThe destination branch id, for example clients/_index. It must already exist and be a branch.
bodystringoptional""Markdown. An empty body becomes # title, and a body not starting with a heading gets one prepended.
tagslist[string]optional[]Indexed by locate alongside title, aliases and summary.
linkslist[{rel, target}]optional[]Typed edges declared by this node. Each rel must be in the dialect, and a node may hold at most 50 links.
confidencefloatoptional1.0Written to frontmatter only when it differs from 1.0.
sourcestringoptional"agent"Provenance, and it appears in the commit message.
payloadstring | nulloptionalnullSibling payload filename. With a schema it defaults to the last id segment plus .db and must be a bare filename ending in .db.
payload_typestring | nulloptionalnullDefaults to sqlite when a schema is present, and nothing else is accepted there.
payload_hashstring | nulloptionalnullComputed by the engine when it births a payload. Supply it only for a payload that already exists.
entity_kindstring | nulloptionalnullRequired on type: entity: person, organization, product, place or other.
aliaseslist[string]optional[]Other names for this node. Indexed by locate.
schemaobject | nulloptionalnullDeclarative dataset schema, table -> {columns, primary_key}. Only valid on type: dataset. See the dataset example below.
rowsobject | nulloptionalnullInitial rows per table, loaded parameterized at birth. Requires schema; every table must be declared and every row must match the column count.

Unknown keys are allowed through into the frontmatter, which is how ingestion carries its own fields such as source_path and source_hash.

Returns

id, commit (the sha of the commit that contains both the new file and the updated parent index) and trail.

Request

{"tool": "plant", "args": {"node": {
  "id": "sales/exchange-policy-2027",
  "type": "note",
  "parent": "sales/_index",
  "title": "Exchange policy 2027",
  "summary": "Return and exchange windows for 2027, per channel, with invoice rules.",
  "tags": ["sales", "policy"],
  "links": [{"rel": "succeeds", "target": "sales/exchange-policy"}],
  "body": "# Exchange policy 2027\n\n## Deadlines\n\nRetail returns close 30 days..."
}}}

Response

json
{
  "id": "sales/exchange-policy-2027",
  "commit": "9f2c1ab4d1e0c3b7a5f8e2d6c4b1a9f7e3d5c2b8",
  "trail": ["_index", "sales/_index"]
}

Planting a dataset

A model never writes DDL. You declare the shape as data and the engine generates the CREATE TABLE statements, births the SQLite file, loads any initial rows with placeholders, computes the payload hash into the frontmatter, and appends a generated ## Query manual (plus a ## Sample rows map when rows were supplied) unless the body already carries a manual.

jsonplant with a declarative dataset schema
{"tool": "plant", "args": {"node": {
  "id": "clients/prospecting-2026",
  "type": "dataset",
  "parent": "clients/_index",
  "title": "Client prospecting 2026",
  "summary": "Prospects collected in 2026, with segment, site and collection date.",
  "schema": {
    "clients": {
      "columns": {"name": "TEXT", "site": "TEXT", "segment": "TEXT",
                  "collected_at": "TEXT"},
      "primary_key": ["name"]
    }
  },
  "rows": {"clients": [["Acme", "acme.example", "industry", "2026-01-14"]]}
}}}

Schema validation, all answering E_SCHEMA:

  • Table and column names match ^[a-z_][a-z0-9_]*$, at most 64 characters.
  • Column types are one of TEXT, INTEGER, REAL, BLOB.
  • primary_key may only name declared columns.
  • At most 10 tables per dataset and 50 columns per table, at least one of each.
  • The target .db must not already exist. A newborn dataset never overwrites a payload.

What the commit contains

The commit carries markdown only: the new .md and the parent _index.md, with the message plant(<id>): <title> [source=<source>]. A SQLite payload never enters git; its identity lives in the frontmatter as payload_hash. If any step after the payload is created fails, the payload is deleted along with the file and the parent index is restored.

graft

Edit a node. Frontmatter, links, one section, or the whole body. Reach for it to correct a summary, record a connection an agent noticed, or append what a hunt learned to an existing note. After birth, dataset rows are tend, not graft.

POST/v1/forests/{forest}/graftwrite

Patch a node, atomically, with a commit.

Parameters

NameTypeRequiredDefaultDescription
idstringrequirednoneThe node to edit.
patchobject (GraftPatch)requirednoneOne or more operations, combinable. An empty patch answers E_SCHEMA.

GraftPatch operations

NameTypeRequiredDefaultDescription
set_frontmatterobjectoptional{}Mutable fields only: title, summary, tags, confidence. Anything else, and in particular id, type and created, answers E_READONLY. A new summary is validated by the same 60 token rules as on plant.
add_linkslist[{rel, target}]optional[]Adds typed edges. An unknown rel answers E_SCHEMA, and so does a patch that would take the node past 50 links. A discovered-shortcut gets confidence: 0.5 and discovered_by: agent by default.
remove_linkslist[{rel, target}]optional[]Removes edges by the pair of rel and target.
append_section{header, body} | nulloptionalnullAdds a new section at the end of the body.
replace_section{header, body} | nulloptionalnullReplaces one section, keeping its header line. A header that does not exist answers E_NOT_FOUND and points at append_section.
replace_bodystring | nulloptionalnullThe whole body at once; the empty string is a valid body. Cannot be combined with the section operations, and is refused on index nodes, whose body is a generated render. The serialized node must re-parse before anything is committed.

Returns

id, commit, fortified and trail. A summary change propagates verbatim to every index that replicates it, inside the same commit.

Request

{"tool": "graft", "args": {
  "id": "sales/exchange-policy",
  "patch": {
    "set_frontmatter": {"summary": "Return and exchange windows per channel, superseded for 2027."},
    "add_links": [{"rel": "succeeds", "target": "sales/exchange-policy-2025"}],
    "append_section": {"header": "Notes",
                       "body": "Superseded by the 2027 policy from January."}
  }}}

Response

json
{
  "id": "sales/exchange-policy",
  "commit": "3c8e07b19a24f6d5b0c7e1a3f9d2b4c6a8e0f7d1",
  "fortified": [],
  "trail": ["_index", "sales/_index"]
}

Reinforce before create

Adding a link that already exists is not an error and not a duplicate. It is fortification: heat goes up on both ends, no edge is created, and if nothing else in the patch changed the file, no commit happens at all. The response says so explicitly.

jsonA graft whose only operation was a duplicate link
{
  "id": "sales/exchange-policy",
  "commit": null,
  "fortified": [{"rel": "related-to", "target": "sales/orders-2026"}],
  "trail": ["_index", "sales/_index"]
}

This is what lets an agent end every successful hunt with the same unconditional call: propose the shortcut, and let the engine decide whether that means a new edge or a stronger one.

tend

Write rows into a dataset. The only sanctioned write path into a payload, and the one that makes a forest memory rather than a reader. query stays read-only forever.

POST/v1/forests/{forest}/tendtend

One INSERT, UPDATE or DELETE against a dataset payload, with an audit commit.

Parameters

NameTypeRequiredDefaultDescription
idstringrequirednoneA node with type: dataset and payload_type: sqlite whose payload exists locally. A remote payload is read-only and answers E_QUERY_FORBIDDEN.
sqlstringrequirednoneExactly one statement starting with INSERT, UPDATE or DELETE. UPDATE and DELETE must carry a WHERE. ATTACH, DETACH, PRAGMA, DROP, ALTER, CREATE, VACUUM, REINDEX, BEGIN, COMMIT and TRANSACTION are forbidden anywhere in the text.

Returns

id, rows_affected, the refreshed payload_hash, the commit of the markdown audit, and elapsed_ms.

Request

{"tool": "tend", "args": {
  "id": "clients/prospecting-2026",
  "sql": "INSERT INTO clients VALUES ('Globex', 'globex.example', 'retail', '2026-08-14')"
}}

Response

json
{
  "id": "clients/prospecting-2026",
  "rows_affected": 1,
  "payload_hash": "b7d41e0a9c3f52d8e6a1b4c7f0d2e5a8c1b3d6f9e2a4c7b0d3f6a9c2e5b8d1f4",
  "commit": "d41a7e0b3c95f8e2a6c0b4d7f1e3a9c2b5d8f0a3",
  "elapsed_ms": 4.2
}

What gets committed

The write lands in the SQLite payload first. Then the node frontmatter is refreshed with the new payload_hash and today's updated, and only the markdown is committed, with the message tend(<id>): <VERB> <n> row(s). The what and when live in the commit stream; the binary never enters git. A failure after the payload committed restores the .md and surfaces the error, and the resulting hash drift is exactly what vine validate reports and the next successful tend repairs.

No schema changes, by design

CREATE, ALTER and DROP are not available to agents. Schema evolution belongs to ingestion, which is where a person can see it. Multi-row inserts are fine, because INSERT INTO t VALUES (...), (...) is a single statement. Both query and tend are killed at 2 seconds with E_TIMEOUT, and a failed statement rolls back with E_QUERY_INVALID leaving the payload untouched.

Composites

Neither of these is a primitive. harvest is a deterministic orchestration of three read primitives with no model anywhere. answer is harvest plus the model bound to the forest, and it lives in the Station, not in the engine.

harvest

One-shot retrieval, zero LLM. A fused locate and sniff sweep that returns the evidence itself: full body when it fits, matched sections when it does not, always with exact snippets and the trail. Reach for it when you want material in one round trip and your own model will do the reasoning.

POST/v1/forests/{forest}/harvestread

Ranked evidence with snippets and content, in a single call.

Parameters

NameTypeRequiredDefaultDescription
querystringrequirednoneFree text, passed to locate as-is.
termslist[string] | nulloptionalnullExact terms for the sniff half. When absent they are derived from the query: words of 4 characters or more, stopwords removed, at most 8.
kintoptional3Maximum results. Clamped to at least 1 and at most the deployment cap MONKEYLLM_HARVEST_MAX_K, which defaults to 5. A value of that variable that is not an integer, or is below 1, is refused with E_SCHEMA naming the variable rather than silently corrected.

How it works

  1. locate(query, k * 2) and sniff(terms, k * 2) run, and their rankings are fused with reciprocal rank fusion. The top k ids survive.
  2. For each survivor, matches are refined by term scarcity: one scoped sniff per term, rarest first, so a rare exact term is not drowned by a common one under the per-node match cap. Index nodes are never refined this way, because a scoped sniff on an index would grep its whole subtree.
  3. Content per node: the full body when it is at most 1200 tokens, otherwise up to 2 matched sections, otherwise the outline as a map.
  4. A dataset result also carries its notes, unconditionally, whether or not the question shares vocabulary with them.

Returns

query, the terms actually used, truncated, and results, each with id, title, type, trail, summary, score, found_by (which of the two rankers found it), matches and content. Every id can be handed straight back to the primitives to continue navigating.

Request

{"tool": "harvest",
 "args": {"query": "what is the deadline to exchange a retail order?",
          "terms": ["exchange", "deadline"], "k": 3}}

Response

json
{
  "query": "what is the deadline to exchange a retail order?",
  "terms": ["deadline", "exchange", "retail", "order"],
  "results": [
    {
      "id": "sales/exchange-policy",
      "title": "Exchange policy",
      "type": "note",
      "trail": ["_index", "sales/_index"],
      "summary": "Return and exchange windows per channel, with invoice rules.",
      "score": 0.0328,
      "found_by": ["locate", "sniff"],
      "matches": [
        {"section": "Deadlines", "line": 23,
         "snippet": "return with invoice NF-4412 within 30 days of receipt"}
      ],
      "content": [
        {"section": null,
         "body": "# Exchange policy\n\n## Channels\n\n...",
         "body_tokens": 612}
      ]
    }
  ],
  "truncated": false
}

Budget and truncation

4000 tokens for the whole bundle. Whole tail results are dropped and truncated is set; a body is never sliced to make room. Over the Station the response also carries a trace, because a call that is several calls has to be able to say which of them was slow.

answer

Retrieval plus the forest's own model. Runs the scoped sweep, hands the bundle to the model bound to this forest for the answer role, and returns grounded prose with its evidence. This one exists only on the Station: the engine has no model and wants none.

POST/v1/forests/{forest}/answerread

A grounded answer read by the model bound to the forest, with its evidence and trace.

Parameters

NameTypeRequiredDefaultDescription
questionstringrequirednoneThe question. query is accepted as an alias on the REST body.
kintoptional3Evidence bundle size, passed to the harvest underneath.
cachebooloptionaltrueAllows a repeat question to be served from the forest answer store, labelled cached: true with a cached_at. A stored answer is only served when the retrieval this call just ran still reads the same, so a changed forest misses cleanly. Pass false to buy a fresh run, which replaces the stored one.
reply_tokensint | nulloptionalnullBounds the reply for this call, clamped to the range 64 to 4000. Absent or zero means the binding decides, and both are the same call and the same cache key.
hopsbool | int | nulloptionalnullOpt in to navigation instead of a single sweep. true means a budget of 6 hops, a number sets it, and the budget is clamped to at most 16. Each hop costs one model call. REST only: the Station MCP tool does not expose it.

Returns

answer, model, model_ms, usage (the provider's own meter), evidence (the ids the answer stands on), sources and the whole harvest bundle the model read. The host then attaches trace and, when the provider publishes rates, cost.

Request

{"tool": "answer",
 "args": {"forest": "acme",
          "question": "what is the deadline to exchange a retail order?",
          "k": 3, "cache": true, "reply_tokens": 300}}

Response

json
{
  "answer": "Retail returns close 30 days after the invoice date...",
  "model": "qwen3:14b",
  "model_ms": 1840.2,
  "usage": {"prompt": 2914, "completion": 118, "calls": 1},
  "evidence": ["sales/exchange-policy"],
  "sources": [
    {"id": "sales/exchange-policy", "title": "Exchange policy",
     "summary": "Return and exchange windows per channel, with invoice rules.",
     "type": "note"}
  ],
  "harvest": { "...": "the full harvest bundle the model read" },
  "trace": {
    "steps": [
      {"step": "locate", "ms": 8.2, "tokens": 410},
      {"step": "sniff", "ms": 21.7, "tokens": 330},
      {"step": "pick", "id": "sales/exchange-policy", "ms": 2.1, "tokens": 168},
      {"step": "model", "ms": 1840.2, "detail": "qwen3:14b"}
    ],
    "retrieval_ms": 32.0,
    "total_ms": 1872.2
  },
  "cost": {"prompt_tokens": 2914, "completion_tokens": 118,
           "calls": 1, "priced": false}
}

Cost is never estimated

usage is what the provider metered, not a local count. When the provider publishes rates, cost carries priced: true and the dollar figures. When it does not, a local Ollama for instance, the answer is priced: false with no usd, because rendering silence as $0.00 would be a claim about money made from the absence of one.

With hops

Passing hops swaps the single sweep for a loop: the model holds the read primitives (locate, sniff, look, move, pick, scan, query) and decides where to go until it can answer. Writes are never in that whitelist, even for a principal who holds the write capability: they asked a question, not for an edit.

Request

bash
curl -sS https://station.example.com/v1/forests/acme/answer \
  -H "Authorization: Bearer $MONKEYLLM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question": "which region closed the most orders in 2026?",
       "k": 3, "hops": 6}'

Response

json
{
  "answer": "Southeast closed 812 orders in 2026, the largest of the four regions.",
  "model": "qwen3:14b",
  "model_ms": 6210.4,
  "turns": 5,
  "hops": [
    {"n": 1, "tool": "look", "id": "sales/orders-2026", "ms": 3.9,
     "model_ms": 1180.2, "ok": true, "args": {}, "out": "dataset digest"},
    {"n": 2, "tool": "query", "id": "sales/orders-2026", "ms": 4.4,
     "model_ms": 1402.7, "ok": true, "args": {}, "out": "3 rows"}
  ],
  "read": [{"...": "what each hop actually returned"}],
  "usage": {"prompt": 9120, "completion": 240, "calls": 5},
  "sources": [{"id": "sales/orders-2026", "...": "..."}],
  "evidence": ["sales/orders-2026"]
}

The extra fields are turns, hops (every call the model made, with the forest milliseconds and the model milliseconds kept apart) and read. Running out of budget does not waste the hunt: a deadline turn forces an answer from what was already read, and a loop that exhausts itself returns exhausted: true with an empty answer rather than a fabricated one.

Retrieval always runs before the model

The sweep executes through the caller's scoped view of the forest, so the model only ever sees material that principal could already have read primitive by primitive. Binding a model to a forest cannot widen what anyone can see. This is why answer requires only the read capability and still cannot leak.

Also on the MCP surface

Three more tools exist alongside the twelve. They are not primitives, and two of them have no REST equivalent on purpose.

Extra tools on the MCP surface
ToolSignatureWhat it does
forestsforests()Lists the forests this server can navigate, as {forests: [{id, active}], mode} where mode is single or registry. Call it first when you do not know which ids exist.
viewview(id)Hands a multimodal client the pixels behind a type: media node, as an MCP image block beside a JSON header of id, media_type, size and payload_hash. Images only, local payloads only, bounded at 6 MiB. Needs the read capability. It does not exist over REST, where bytes are served by the payload route instead.
close_sessionclose_session(success, answer_nodes)Closes a hunt: reinforces heat along the winning trail and returns the session metrics. This is what turns a successful navigation into a cheaper one next time.

Choosing between them

Most of the cost of a hunt is decided in the first call. These are the three decisions worth getting right.

locate, sniff or harvest

All three can start a hunt, and they fail in different directions.

Choosing between locate, sniff and harvest
Start withWhen the questionBecause
locateIs conceptual: what is our exchange policy, what do we know about the Southeast.It reads curated metadata, which is where a topic is named. It finds the right region even when your words are not the document's words. It cannot find a string nobody summarized.
sniffCarries an exact rare string: an invoice number, an SKU, an error code, a person's full name.It reads bodies literally and returns the line and the section, so the next call is pick(id, section) and the hunt is two hops. It cannot generalize: a synonym finds nothing.
harvestIs either of the above, and you would rather spend one round trip than five.It runs both and fuses them, then returns the content itself. You pay up to 4000 tokens for evidence you may not need, and you give up reasoning during navigation.

The chained form is often the best of the three:

  1. locate the region for a broad question, with scope: "branches".
  2. sniff(terms, scope=<that branch>) to find the exact line inside it.
  3. pick(id, section) to read only what matters.

Reach for harvest when the client is a capable model that will reason over evidence in one turn, and for the primitives when reasoning has to happen between the hops, which is exactly the case a top-k retrieval cannot serve.

scan or move

Both enumerate what is around a node, and they answer different questions.

  • Use move when the relationship is the point. You want what this node links to, along a named edge, in a chosen direction. It returns full summaries and it is how you follow the graph rather than the folder tree. It has no filter and no k, so on a hub node it truncates.
  • Use scan when the property is the point. You want the children of a region that are datasets, or were updated after a date, or carry a tag. It is served from the catalog with no file opened, it takes recursive for a whole subtree, and fields lets you ask for three columns instead of a full summary each.

Concretely: move(id, rel="children") on a branch with 60 children will spend its 600 tokens on summaries and truncate. scan(parent_id, filter={...}, fields=["id", "title"]) answers the same shape of question in one call, filtered, in roughly 200 tokens. If you find yourself calling move and then discarding most of the neighbours, it should have been a scan.

When answer is the wrong tool

answer is the shortest path from a question to prose, and there are four situations where it is the wrong call.

  • Your client already has a model. Then answer spends the Station's tokens to produce text your own model was going to produce anyway, and it hides the evidence behind somebody else's summary. Call harvest and reason yourself.
  • The answer is an aggregate over rows. The default sweep returns a dataset's prose, never its payload, so a question such as what was total revenue is answered from whatever nearby text mentions a figure. That is a wrong answer indistinguishable from a right one. Use look then query, or pass hops so the model can run query itself.
  • You need the reasoning to be auditable step by step. A sweep answer carries its evidence, but the navigation that produced it was one deterministic fusion. When you need to see, and gate, each decision, drive the primitives from your own loop.
  • You are writing. answer only reads, and its hop whitelist contains no write primitive. Creating and editing nodes is plant, graft and tend, called deliberately.

Where answer is exactly right: a product surface that needs grounded prose with citations, over a forest whose bound model you control, where a repeated question should be cheap and where you want the timing split between the forest and the provider handed to you in the response.

Next steps

Documents MonkeyLLM v0.1.0. Last reviewed against the engine source on .