The twelve calls
Seven primitives read, three write, and two composites orchestrate the others. Nothing else touches a forest.
| Call | Reach for it when | Budget | Capability |
|---|---|---|---|
locate | You have a question and no id. Ranked entry points over curated metadata. | 800 | read |
look | You have an id and want to know what it holds before paying for the body. | 500 | read |
move | You are standing on a node and want its neighbours along typed edges. | 600 | read |
pick | The summary says this is the one. Take the body, or one section of it. | 4000 | read |
scan | You want a filtered list of a branch, by metadata, without opening files. | 800 | read |
sniff | The question carries an exact term summaries never lift: a code, a number, a name. | 800 | read |
query | The answer is an aggregate over rows, not a sentence in prose. | 2000 | query |
plant | Something new is worth keeping. Creates a node, indexes it, commits it. | tiny | write |
graft | An existing node is wrong, incomplete, or missing a link you just learned. | tiny | write |
tend | Rows enter, change or leave a dataset payload. | tiny | tend |
harvest | You want evidence in one call and will reason over it with your own model. | 4000 | read |
answer | You want prose back, read by the model bound to the forest, with its evidence attached. | model | read |
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: truemeans 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
hintsaying 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")# stdio, for an agent on the same machine
vine serve --forest ./brain
# streamable HTTP, for anything else
vine serve --forest ./brain --transport http --host 0.0.0.0 --port 8000
# every tool then takes the arguments documented below:
{"tool": "locate", "args": {"query": "exchange policy deadlines", "k": 3}}# the Station serves every primitive at one route shape
curl -sS https://station.example.com/v1/forests/acme/locate \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "exchange policy deadlines", "k": 3}'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.
{
"error": {
"code": "E_QUERY_INVALID",
"message": "SQL error: no such column: total_value",
"hint": "Columns: orders(id, customer, region, total_invoice, closed_at)."
}
}| Code | Means | HTTP |
|---|---|---|
E_NOT_FOUND | No such node, section, or forest. On a host, also what an out-of-scope node answers, byte for byte. | 404 |
E_SCHEMA | The request is malformed: bad argument, invalid node spec, unknown rel. | 400 |
E_FRONTMATTER | A node passport failed validation, most often the summary rules. | 400 |
E_READONLY | A write against a read-only Vine, or an immutable frontmatter field. | 403 |
E_QUERY_FORBIDDEN | The SQL guard refused: not a dataset, several statements, wrong leading keyword, forbidden keyword, UPDATE or DELETE without WHERE. | 403 |
E_QUERY_INVALID | The statement passed the guard and SQLite refused it: unknown table or column, syntax error. | 400 |
E_TIMEOUT | A dataset statement exceeded the 2 second deadline. | 504 |
E_LOCKED | Another writer holds the forest lock. | 409 |
E_FORBIDDEN | Host 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.
/v1/forests/{forest}/locatereadRanked entry points for a free-text query.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | required | none | Free text. Matched against title, aliases, tags and summary. |
k | int | optional | 5 | Maximum 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_filter | string | null | optional | null | Keeps 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"}}curl -sS https://station.example.com/v1/forests/acme/locate \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "exchange policy deadlines", "k": 3}'Response
{
"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.
/v1/forests/{forest}/lookreadThe bounded digest of a single node.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | Node id, for example sales/exchange-policy or sales/_index. Unknown id answers E_NOT_FOUND. |
fields | list[string] | null | optional | null | Return 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. |
gauntlet | bool | null | optional | null | Frontier 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. |
toward | string | null | optional | null | An 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 withidand a truncatedsummary), pluscross_trailsandcoveragewhen 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), andnotes, 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"]}}curl -sS https://station.example.com/v1/forests/acme/look \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "sales/exchange-policy"}'Response
{
"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.
{
"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.
/v1/forests/{forest}/movereadNeighbours of a node along typed edges.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | The node you are standing on. |
rel | string | null | optional | null | Keep 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. |
gauntlet | bool | null | optional | null | Frontier ordering, as on look. Not exposed on either MCP tool. |
toward | string | null | optional | null | Explicit 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"}}curl -sS https://station.example.com/v1/forests/acme/move \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "sales/_index", "rel": "children"}'Response
{
"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.
/v1/forests/{forest}/pickreadThe body of a node, or one of its sections.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | The node whose body you want. |
section | string | null | optional | null | A 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"}}curl -sS https://station.example.com/v1/forests/acme/pick \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "sales/exchange-policy", "section": "Deadlines"}'Response
{
"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.
{
"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.
/v1/forests/{forest}/scanreadFilter a branch's children by metadata, without opening files.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
parent_id | string | required | none | The branch to scan, for example sales/_index or the root _index. |
filter | object | null | optional | null | Metadata predicates, all combined with AND. See the table below. An unknown key answers E_SCHEMA. |
fields | list[string] | null | optional | ["id", "type", "summary"] | Which columns to return. id is always included. Not exposed on the Station MCP surface. |
recursive | bool | optional | false | Descend the whole subtree instead of the branch direct children. |
limit | int | optional | 50 | Maximum nodes returned. Clamped to the range 1 to 50. |
gauntlet | bool | null | optional | null | Frontier ordering, as on look. Not exposed on either MCP tool. |
toward | string | null | optional | null | Explicit goal text for frontier ordering. Not exposed on either MCP tool. |
Filter keys
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
tags_any | list[string] | optional | none | Node carries at least one of these tags. |
updated_after | string (ISO date) | optional | none | Node updated strictly after this date. |
updated_before | string (ISO date) | optional | none | Node updated strictly before this date. |
created_after | string (ISO date) | optional | none | Node created strictly after this date. |
min_confidence | float | optional | none | Node confidence at or above this value. |
<any catalog column> | value | optional | none | Exact 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}}curl -sS https://station.example.com/v1/forests/acme/scan \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"parent_id": "_index",
"filter": {"type": "dataset", "tags_any": ["sales"]},
"recursive": true}'Response
{
"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.
/v1/forests/{forest}/sniffreadLiteral search inside node bodies, with the matching lines.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
terms | string | list[string] | required | none | 1 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. |
scope | string | null | optional | null | Any 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. |
k | int | optional | 5 | Maximum nodes in the result. Clamped to the range 1 to 20. |
type_filter | string | null | optional | null | Keep 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}}curl -sS https://station.example.com/v1/forests/acme/sniff \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"terms": ["NF-4412", "exchange"], "scope": "sales", "k": 5}'Response
{
"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_matchesper node means that node had more than 3 matching lines.match_counttells you how many.truncatedat the top level means whole nodes were dropped from the tail, either pastkor 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.
/v1/forests/{forest}/queryqueryA single read-only statement against a dataset node SQLite payload.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | A node with type: dataset and payload_type: sqlite. Anything else answers E_QUERY_FORBIDDEN. |
sql | string | required | none | Exactly 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"}}curl -sS https://station.example.com/v1/forests/acme/query \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "sales/orders-2026",
"sql": "SELECT region, COUNT(*) AS orders FROM orders GROUP BY region"}'Response
{
"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.
{
"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.
/v1/forests/{forest}/plantwriteCreate a node: frontmatter, body, parent entry and commit, atomically.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
node | object (NodeSpec) | required | none | The full node specification. Fields below. |
NodeSpec fields
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | Path-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. |
type | string | required | none | One of note, document, dataset, entity, concept, event, media, branch. An unknown type answers E_SCHEMA. |
title | string | required | none | Human title. Becomes the body H1 when the body has none. |
summary | string | required | none | The 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. |
parent | string | required | none | The destination branch id, for example clients/_index. It must already exist and be a branch. |
body | string | optional | "" | Markdown. An empty body becomes # title, and a body not starting with a heading gets one prepended. |
tags | list[string] | optional | [] | Indexed by locate alongside title, aliases and summary. |
links | list[{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. |
confidence | float | optional | 1.0 | Written to frontmatter only when it differs from 1.0. |
source | string | optional | "agent" | Provenance, and it appears in the commit message. |
payload | string | null | optional | null | Sibling payload filename. With a schema it defaults to the last id segment plus .db and must be a bare filename ending in .db. |
payload_type | string | null | optional | null | Defaults to sqlite when a schema is present, and nothing else is accepted there. |
payload_hash | string | null | optional | null | Computed by the engine when it births a payload. Supply it only for a payload that already exists. |
entity_kind | string | null | optional | null | Required on type: entity: person, organization, product, place or other. |
aliases | list[string] | optional | [] | Other names for this node. Indexed by locate. |
schema | object | null | optional | null | Declarative dataset schema, table -> {columns, primary_key}. Only valid on type: dataset. See the dataset example below. |
rows | object | null | optional | null | Initial 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..."
}}}curl -sS https://station.example.com/v1/forests/acme/plant \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"node": {"id": "sales/exchange-policy-2027", "type": "note",
"parent": "sales/_index", "title": "Exchange policy 2027",
"summary": "Return and exchange windows for 2027, per channel.",
"body": "# Exchange policy 2027\n\n..."}}'Response
{
"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.
{"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_keymay only name declared columns.- At most 10 tables per dataset and 50 columns per table, at least one of each.
- The target
.dbmust 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.
/v1/forests/{forest}/graftwritePatch a node, atomically, with a commit.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | The node to edit. |
patch | object (GraftPatch) | required | none | One or more operations, combinable. An empty patch answers E_SCHEMA. |
GraftPatch operations
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
set_frontmatter | object | optional | {} | 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_links | list[{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_links | list[{rel, target}] | optional | [] | Removes edges by the pair of rel and target. |
append_section | {header, body} | null | optional | null | Adds a new section at the end of the body. |
replace_section | {header, body} | null | optional | null | Replaces one section, keeping its header line. A header that does not exist answers E_NOT_FOUND and points at append_section. |
replace_body | string | null | optional | null | The 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."}
}}}curl -sS https://station.example.com/v1/forests/acme/graft \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "sales/exchange-policy",
"patch": {"add_links": [{"rel": "related-to",
"target": "sales/orders-2026"}]}}'Response
{
"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.
{
"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.
/v1/forests/{forest}/tendtendOne INSERT, UPDATE or DELETE against a dataset payload, with an audit commit.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | A node with type: dataset and payload_type: sqlite whose payload exists locally. A remote payload is read-only and answers E_QUERY_FORBIDDEN. |
sql | string | required | none | Exactly 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')"
}}curl -sS https://station.example.com/v1/forests/acme/tend \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "clients/prospecting-2026",
"sql": "UPDATE clients SET segment = '"'"'industry'"'"' WHERE name = '"'"'Globex'"'"'"}'Response
{
"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.
/v1/forests/{forest}/harvestreadRanked evidence with snippets and content, in a single call.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | required | none | Free text, passed to locate as-is. |
terms | list[string] | null | optional | null | Exact terms for the sniff half. When absent they are derived from the query: words of 4 characters or more, stopwords removed, at most 8. |
k | int | optional | 3 | Maximum 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
locate(query, k * 2)andsniff(terms, k * 2)run, and their rankings are fused with reciprocal rank fusion. The topkids survive.- For each survivor, matches are refined by term scarcity: one scoped
sniffper 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. - 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.
- 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}}curl -sS https://station.example.com/v1/forests/acme/harvest \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "what is the deadline to exchange a retail order?", "k": 3}'Response
{
"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.
/v1/forests/{forest}/answerreadA grounded answer read by the model bound to the forest, with its evidence and trace.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
question | string | required | none | The question. query is accepted as an alias on the REST body. |
k | int | optional | 3 | Evidence bundle size, passed to the harvest underneath. |
cache | bool | optional | true | Allows 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_tokens | int | null | optional | null | Bounds 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. |
hops | bool | int | null | optional | null | Opt 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}}curl -sS https://station.example.com/v1/forests/acme/answer \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H "Content-Type: application/json" \
-d '{"question": "what is the deadline to exchange a retail order?", "k": 3}'Response
{
"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
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
{
"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.
| Tool | Signature | What it does |
|---|---|---|
forests | forests() | 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. |
view | view(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_session | close_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.
| Start with | When the question | Because |
|---|---|---|
locate | Is 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. |
sniff | Carries 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. |
harvest | Is 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:
locatethe region for a broad question, withscope: "branches".sniff(terms, scope=<that branch>)to find the exact line inside it.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
movewhen 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 nok, so on a hub node it truncates. - Use
scanwhen 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 takesrecursivefor a whole subtree, andfieldslets 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
answerspends 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. Callharvestand 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
lookthenquery, or passhopsso the model can runqueryitself. - 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.
answeronly reads, and its hop whitelist contains no write primitive. Creating and editing nodes isplant,graftandtend, 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.