Before you start
Every flow below runs on a forest, which is a directory of markdown under git. Two of them run entirely on a laptop with no server; two use the Station, which is the same engine behind an HTTP surface with keys, grants and an audit log. The primitives are identical in both.
- The engine needs Python 3.11 or newer and
pip install -e .from the repository. Nothing else is required to run flows 1 and 3. - A model is optional at ingest and required only for the
answercomposite. Adopting a folder without one still produces a navigable forest, with factual template summaries in place of written ones. - The JSON blocks marked
toolare MCP tool calls, which is what an agent runtime emits. The same arguments go in the body ofPOST /v1/forests/{forest}/{primitive}over REST, and in the keyword arguments of the Python method.
1. A case folder in three hops
Goal. A litigation team has 340 files in a shared folder. Somebody asks: which supplier signed the amendment that changed the delivery window, and what did QA say about them? No single document answers that. The supplier is named in the amendment; the QA finding is in a different document, filed under a different branch, that never mentions the amendment at all.
This is the shape a top-k retriever fails on, and not by a little: the second hop is only knowable after the first has been read.
Setup
- 1
Create the forest and mirror the folder in
bashshell# 1. An empty forest, git and all. vine init --forest ./forests/acme-v-northwind --title "Acme v. Northwind" # 2. Point curation at any OpenAI-compatible /v1. Without this, # adopt still works: it just writes factual template summaries # instead of model-written ones. export MONKEYLLM_LLM_ENDPOINT=http://localhost:8090/v1 export MONKEYLLM_LLM_MODEL=qwen3-30b-a3b # 3. Mirror the case folder in. Folders become branches, files become # nodes, and each one gets a passport: title, summary, tags, edges. vine adopt ./case-files --forest ./forests/acme-v-northwind --curate - 2
Read the report, because it tells you what did not land
textadopt outputplanted (312): contracts/msa-2025 contracts/amendment-04 correspondence/2026-01-19-northwind-ap qa/report-2026-02 … branches (14): contracts/_index correspondence/_index qa/_index … unsupported (26): exhibits/site-photos.zip exhibits/site-plan.dwg … rollup: 14 branch(es) rolled, 0 fallback(s), 0 skipped curation: {'llm_summaries': 312, 'fallbacks': 0, 'retries': 4, 'skipped': 0, 'links_proposed': 96, 'proposal_fallbacks': 0, 'branch_rollups': 14, 'branch_fallbacks': 0, 'transport_errors': 0, 'rejected': 0, 'repaired': 2}Nothing is dropped silently. The 26 unsupported files are listed by name, so you can decide whether to add a converter for them. PDFs get in through a one-line command hook in the forest config, since a good extractor is usually a copyleft dependency the engine will not force on you.
- 3
Serve it to the agent
bashshell# Hand the forest to any MCP-capable agent, on this machine, no host. vine serve --forest ./forests/acme-v-northwind # Or read-only, which is what you want for a review agent: vine serve --forest ./forests/acme-v-northwind --readonly
What the agent does
Five calls, roughly 2,600 tokens of context in total. Each read is budgeted, so the agent can afford to look before it commits to reading.
Hop 0: get the map
Call
{"tool": "look", "args": {"id": "_index"}}Digest, 500-token budget
{
"id": "_index",
"type": "branch",
"title": "Acme v. Northwind",
"summary": "Discovery materials for Acme v. Northwind: the supply
agreement, its amendments, correspondence and QA records.",
"tags": [],
"confidence": 1.0,
"updated": "2026-08-12",
"edges_out": [],
"edges_in": [],
"children": [
{ "id": "contracts/_index", "summary": "The MSA and its four amendments…" },
{ "id": "correspondence/_index", "summary": "Email between the parties…" },
{ "id": "qa/_index", "summary": "Incoming-goods QA reports, 2025 to 2026…" }
],
"coverage": "0 bananas, 14 sub-branches",
"stats": { "body_tokens": 118, "degree": 0, "heat": 0.0 }
}Hop 1: grep the bodies, not the summaries
"Delivery window" is a literal phrase in a clause. That is a sniff, not a locate: metadata search would rank on curated summaries, and no summary quotes clause 3.
Call
{"tool": "sniff", "args": {"terms": ["delivery window"], "k": 5}}Node, section, exact line
{
"results": [
{
"id": "contracts/amendment-04",
"type": "document",
"title": "Amendment 04 to the supply agreement",
"trail": ["_index", "contracts/_index"],
"score": 1.0,
"heat": 0.0,
"match_count": 3,
"truncated_matches": false,
"matches": [
{
"section": "Clause 3 (Delivery)",
"line": 41,
"snippet": "…the delivery window is amended from 14 to 21 days…"
}
]
}
],
"scanned_nodes": 312,
"truncated": false
}Hop 2: who signed it
The digest is enough. The agent does not need the 1,840-token body to learn the supplier: the incoming edge already names the entity, and the outline says where clause 3 lives if it wants to quote it later.
Call
{"tool": "look", "args": {"id": "contracts/amendment-04"}}Digest
{
"id": "contracts/amendment-04",
"type": "document",
"title": "Amendment 04 to the supply agreement",
"summary": "Signed 2026-01-08. Extends the delivery window from 14 to
21 days and re-prices expedited freight.",
"tags": ["contract", "delivery", "amendment"],
"confidence": 1.0,
"updated": "2026-08-12",
"outline": ["Parties", "Clause 3 (Delivery)", "Clause 7 (Freight)", "Signatures"],
"edges_out": [
{ "rel": "succeeds", "target": "contracts/amendment-03",
"target_summary": "Signed 2025-09-02. Adjusted the penalty schedule." }
],
"edges_in": [
{ "rel": "mentions", "source": "entities/northwind-logistics" }
],
"stats": { "body_tokens": 1840, "degree": 2, "heat": 0.1 }
}Hop 3: what else mentions that supplier
Call
{"tool": "move", "args": {"id": "entities/northwind-logistics"}}Neighbours, hottest first
{
"neighbors": [
{ "id": "contracts/amendment-04", "rel": "mentioned-in", "direction": "out",
"type": "document", "summary": "Signed 2026-01-08. Extends the…",
"heat": 0.1 },
{ "id": "qa/report-2026-02", "rel": "mentioned-in", "direction": "out",
"type": "document",
"summary": "February 2026 incoming-goods QA: 4 of 19 Northwind
pallets failed the moisture check.",
"heat": 0.0 }
],
"truncated": false
}Hop 4: read one section, not one document
Call
{"tool": "pick",
"args": {"id": "qa/report-2026-02", "section": "Findings"}}One section
{
"id": "qa/report-2026-02",
"title": "Incoming-goods QA, February 2026",
"section": "Findings",
"body": "## Findings\n\n4 of 19 pallets received from Northwind
Logistics failed the moisture check (limit 14%, measured
16.2% to 19.8%). Two pallets were quarantined; two were
accepted under concession CN-2026-11.",
"body_tokens": 96,
"truncated": false
}The result
The agent answers with two citable node ids, and then closes the hunt. Closing is not bookkeeping: it deposits pheromone on the trail that worked, so the next question in this neighbourhood starts warmer. A chain long enough to be worth a shortcut comes back on the response as suggest_shortcuts, and the orchestrator (not the Ranger) decides whether to mint a discovered-shortcut edge at confidence 0.5. The Ranger only promotes or prunes edges that already exist.
{"tool": "close_session",
"args": {"success": true,
"answer_nodes": ["contracts/amendment-04", "qa/report-2026-02"]}}Why this beats a bigger k
Raising a retriever to k=20 does not solve this: the QA report is not lexically similar to the question, it is similar to something the first document said. The forest holds that relationship as an edge, so the second hop is a lookup rather than a search.
2. A knowledge base, scoped per team
Goal. One company handbook, several audiences. Support needs the refund policy and the product pages. It must not reach the compensation bands, and it must not see next year's roadmap while it is embargoed. The person granting access should not have to move files to express that, and afterwards somebody should be able to answer "who read what".
Setup
- 1
Run the Station
bashshell# The Station: one process, three surfaces (Studio, REST, MCP), # many forests, one registry holding principals, keys and grants. station serve --root /forests --registry /registry/station.db \ --host 0.0.0.0 --port 8800 --writable # First boot prints the setup instructions. The first person to open # the console becomes the owner: admin on every forest, present and # future. Nothing is minted by merely starting the server. - 2
The forest, as it sits on disk
text/forests/handbookhandbook/ ├── _index.md master index: names every branch ├── engineering/ │ ├── _index.md │ ├── oncall-rotation.md │ └── incident-2026-04.md ├── people/ │ ├── _index.md │ └── compensation-bands.md ← nobody outside People reads this ├── product/ │ ├── _index.md │ ├── pricing.md │ └── roadmap/ │ └── 2027-themes.md ← embargoed └── support/ ├── _index.md └── refund-policy.mdScope is expressed against these paths. A node id is its path relative to the forest root without the extension, so
product/as an allow prefix means exactly theproductsubtree. Prefixes compare with a trailing slash, so a grant onproduct/cannot accidentally swallow aproduct-secret/beside it. - 3
Grant, and mint the key in the same call
POST /v1/admin/grant
bashcurl -sX POST https://station.example.com/v1/admin/grant \ -H "Authorization: Bearer $OWNER_KEY" \ -H 'content-type: application/json' \ -d '{ "forest": "handbook", "principal": "support-bot", "caps": ["read", "query"], "allow": ["support/", "product/"], "deny": ["product/roadmap/"], "issue_key": true }'200
json{ "principal": "support-bot", "grants": [ { "forest": "handbook", "caps": ["query", "read"], "allow": ["support/", "product/"], "deny": ["product/roadmap/"], "tables": {} } ], "api_key": "mk_7Qd1…" }The key is shown once
Only a digest is stored. Copy it now or mint another: there is no route that reads a key back, by construction, so a stolen registry file yields no usable credentials.
- 4
Or do it from a shell, for the whole-forest case
textshell# The same grant from a shell, when the whole forest is the scope: station key --principal ci-docs --forest handbook --caps read,query principal: ci-docs forest: handbook caps: query,read API key: mk_9Fk2… Store it now. The Station keeps only its digest.
What each key can and cannot see
The first thing a scoped agent should call is /v1/me (or the forests() MCP tool). It answers with the grant as policy resolves it, including the roots to start from, which matters because a scoped key has no master index to look at.
GET /v1/me
curl -s https://station.example.com/v1/me \
-H "Authorization: Bearer mk_7Qd1…"200
{
"principal": "support-bot",
"grants": [
{
"forest": "handbook",
"caps": ["query", "read"],
"allow": ["support/", "product/"],
"deny": ["product/roadmap/"],
"tables": {},
"roots": ["support/_index", "product/_index"]
}
],
"admin": false,
"owner": false
}POST /v1/forests/handbook/look {"id": "support/_index"}
POST /v1/forests/handbook/look {"id": "product/pricing"}
POST /v1/forests/handbook/locate {"query": "refund window", "k": 5}
# locate returns only in-scope nodes, and the trail on each result is
# filtered too: a result under product/ shows
# "trail": ["product/_index"]
# with the master _index removed, because this key was never granted it.POST /v1/forests/handbook/look {"id": "_index"}
POST /v1/forests/handbook/look {"id": "people/compensation-bands"}
POST /v1/forests/handbook/look {"id": "product/roadmap/2027-themes"}
# All three answer 404 with the SAME envelope a genuinely missing
# node answers. deny wins over allow at any depth, and there is no
# implicit grant on the ancestors of a granted subtree: the master
# index names every branch, so handing it out would defeat the grant.
{
"error": {
"code": "E_NOT_FOUND",
"message": "node not found: people/compensation-bands",
"hint": "Use locate() to find entry points."
}
}POST /v1/forests/handbook/plant {"node": {…}}
# 403. The node id came from the caller, so nothing is disclosed by
# saying "forbidden" here, unlike a read, where it would be an
# existence oracle.
{
"error": {
"code": "E_FORBIDDEN",
"message": "'plant' requires the 'write' capability",
"hint": "This principal holds: ['query', 'read']."
}
}Out of scope is indistinguishable from absent
A node this key may not read answers E_NOT_FOUND, byte for byte what a genuinely missing node answers, including through move, whose edges would otherwise disclose a hidden neighbour. Filtering also happens before the caller-visible cut, and every derived count is recomputed from what survived, because a coverage or degree taken over the whole forest would itself be a disclosure.
Narrowing further: one dataset, one table
Branch prefixes scope the tree. Inside a dataset node, a grant can go one level finer and name which tables the SQL may touch.
curl -sX POST https://station.example.com/v1/admin/grant \
-H "Authorization: Bearer $OWNER_KEY" \
-H 'content-type: application/json' \
-d '{
"forest": "handbook",
"principal": "support-bot",
"caps": ["read", "query"],
"allow": ["support/", "finance/"],
"tables": { "finance/ledger": ["invoices"] }
}'
# The dataset node is now readable and one of its tables is queryable.
# A statement naming any other table is refused before SQLite sees it:
#
# {"error": {"code": "E_FORBIDDEN",
# "message": "table not permitted: payroll",
# "hint": "This principal may read: ['invoices']."}}The audit trail
Two halves, each stored where it belongs. Reads land in the host registry: who, which forest, which call, an argument digest, the result size, and when. Bodies and snippets are never copied, because the log records access, not content.
GET /v1/admin/audit
curl -s "https://station.example.com/v1/admin/audit?limit=5&principal=support-bot" \
-H "Authorization: Bearer $OWNER_KEY"200
{
"entries": [
{
"ts": "2026-08-14T09:41:07+00:00",
"principal": "support-bot",
"forest": "handbook",
"primitive": "pick",
"args": "{\"id\": \"support/refund-policy\"}",
"result": "ok",
"size": 2914,
"commit_sha": null
},
{
"ts": "2026-08-14T09:41:05+00:00",
"principal": "support-bot",
"forest": "handbook",
"primitive": "locate",
"args": "{\"query\": \"refund window\", \"k\": 5}",
"result": "ok",
"size": 1180,
"commit_sha": null
},
{
"ts": "2026-08-14T09:38:52+00:00",
"principal": "support-bot",
"forest": "handbook",
"primitive": "look",
"args": "{\"id\": \"people/compensation-bands\"}",
"result": "error",
"size": 141,
"commit_sha": null
}
]
}Note the third entry: the refused read is logged as result: "error". An attempt on a node outside the grant is exactly the thing you want in the log, and it is there even though the caller was told the node does not exist.
Writes are not in that table, because they are already in the forest's own git history, each commit carrying the acting principal:
$ git -C /forests/handbook log --format='%h %s%n%b' -2
3f9a1c2 plant(support/refund-policy-2026): Refund policy 2026 [source=agent]
station-principal: support-bot
8c14e0d tend(finance/ledger): INSERT 1 row(s)
station-principal: finance-syncTogether the two reconstruct any answer after the fact: which principal, which primitives, which nodes, in which order.
3. An agent that remembers
Goal. An assistant learns something durable in Monday's conversation. On Thursday, in a new process with an empty context window, it should recall that fact without anybody re-pasting it, and a human should be able to read and correct what it believes.
No memory service, no vector database, no serialisation format of your own. The state is a folder under git that any later run reopens.
Setup
- 1
A forest, and one branch to keep memories in
bashshell# A forest that exists to be written to. vine init --forest ~/brain --title "Working memory" # One branch for what agents learn, so a human can review it in one # place. Any MCP client could plant it; here it is three lines of Python. python - <<'PY' import os from monkeyllm import Vine with Vine(os.path.expanduser("~/brain")) as vine: vine.plant({"id": "memory/_index", "type": "branch", "parent": "_index", "title": "Memory", "summary": "What agents learned in earlier sessions, one node each."}) PYA dedicated branch is a convention, not a requirement, and it is worth the discipline: it gives a human one place to review what the agent believes.
- 2
Register it with the agent runtime
mcp config{ "mcpServers": { "monkeyllm": { "command": "vine", "args": ["serve", "--forest", "/Users/you/brain"] } } }{ "mcpServers": { "monkeyllm": { "type": "http", "url": "https://station.example.com/mcp/", "headers": { "Authorization": "Bearer mk_…" } } } }The stdio server writes as whoever runs it. Over a Station the same tools are gated by capability:
plantandgraftneedwrite, which is deliberately not something a self-service paired key can carry.
Monday: it plants what it learned
The one field that decides whether this is ever found again is summary. It is the text locate ranks and the line every neighbouring node shows about this one. Write it as the sentence a future search would match.
Call
{"tool": "plant", "args": {"node": {
"id": "memory/northwind-net-45",
"type": "event",
"parent": "memory/_index",
"title": "Northwind moved to net-45 payment terms",
"summary": "From 2026-04-01 Northwind bills on net-45, not our standard
net-30. Agreed with their AP lead during the renewal call.",
"tags": ["billing", "northwind"],
"source": "agent",
"links": [{"rel": "related-to", "target": "memory/_index"}],
"body": "# Northwind moved to net-45 payment terms\n\n## What changed\n\n
Applies to every invoice issued from 2026-04-01.\n\n
## Where it came from\n\nRenewal call, 2026-03-27."
}}}Atomic: file, index entry and commit
{
"id": "memory/northwind-net-45",
"commit": "b7e42a1c9d0f5e3a8c1b6d4f2e9a7c5b3d1f8e6a",
"trail": ["_index", "memory/_index"]
}Thursday: it finds it again
New process, empty context, no state carried across. The agent searches the whole forest rather than a memory API: a fact it planted on Monday and a document somebody ingested this morning are both just nodes.
Call
{"tool": "locate",
"args": {"query": "what payment terms does Northwind have", "k": 3}}Ranked entry points
{
"results": [
{
"id": "memory/northwind-net-45",
"kind": "banana",
"type": "event",
"title": "Northwind moved to net-45 payment terms",
"summary": "From 2026-04-01 Northwind bills on net-45, not our
standard net-30. Agreed with their AP lead…",
"trail": ["_index", "memory/_index"],
"score": 0.9412,
"heat": 0.13
}
],
"truncated": false
}heat is why recall improves with use. Every read deposits pheromone and a closed session reinforces the trail that answered, so a memory that keeps being useful keeps rising. It also decays: heat has a 30-day half-life by default and cold rows are swept away as dust, so a note nobody has needed in months stops crowding the ones people do.
When the fact changes, edit it
A memory that was true in April and wrong in July is not a new node. Editing is graft, and it is the same atomic commit discipline as planting.
{"tool": "graft", "args": {
"id": "memory/northwind-net-45",
"patch": {
"set_frontmatter": {
"summary": "From 2026-04-01 Northwind bills on net-45. Superseded
2026-07: back to net-30 after the volume rebate lapsed."
},
"append_section": {
"header": "Correction",
"body": "2026-07-02: reverted to net-30. The net-45 term was tied
to the volume rebate, which lapsed with the renewal."
}
}
}}And because it is git, the belief has a history a person can read:
$ git -C ~/brain log --oneline -3
a91f0c4 graft(memory/northwind-net-45): set summary; append 'Correction'
b7e42a1 plant(memory/northwind-net-45): Northwind moved to net-45 payment terms [source=agent]
2d80fb3 plant(memory/_index): Memory [source=agent]4. Spreadsheets answered with SQL
Goal. Finance exports three workbooks a month. Somebody asks which region led EUR revenue. Chunking a spreadsheet into prose destroys exactly the thing that would answer that, so the spreadsheet stays a table and the agent writes SQL against it.
Setup
- 1
Install the workbook readers and adopt the folder
bashshell# The workbook readers are an optional extra: openpyxl for .xlsx, # xlrd for .xls, python-docx for .docx. Without them those three # extensions are reported "unsupported", never silently dropped. pip install -e ".[ingest]" vine init --forest ./forests/revenue --title "Revenue" # --dest files the batch under a branch of your choosing. Without it, # a top-level export lands as bare "sales-2026" under "_index": ids # come from the path RELATIVE to the source root, so the source # folder's own name is never part of them. vine adopt ./finance-exports --forest ./forests/revenue --dest financetextadopt outputplanted (3): finance/sales-2026 finance/targets-2026 finance/headcount-2026 branches (1): finance/_index # One dataset node per workbook, one SQLite table per sheet, types # inferred. The passport body carries a "## Query manual" (tables and # columns) and "## Sample rows" (three rows per table, as a pipe # table). That is the only thing sniff can see inside a payload, # and therefore what makes a spreadsheet findable by what it CONTAINS. - 2
Or send the file to a Station
bashshell# The same thing from a browser or a script, against a Station. # b64 rather than text, because the xlsx converter reads bytes. curl -sX POST https://station.example.com/v1/forests/revenue/ingest \ -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \ -d "{\"mode\": \"upload\", \"dest\": \"finance\", \"files\": [{\"name\": \"sales-2026.xlsx\", \"b64\": \"$(base64 -i sales-2026.xlsx)\"}]}" # 202 Accepted with a job, because a batch is a job: # {"job": {"id": "…", "forest": "revenue", "mode": "upload", # "state": "running", "done": 0, "total": 1, "stage": "convert"}} # # Poll it, or send "wait": true and get the finished job in one response. curl -s https://station.example.com/v1/forests/revenue/jobs/<job> \ -H "Authorization: Bearer $KEY"
A 5 MB export and a 5 GB database cost the same to curate
The ingest model reads only the generated map, roughly 150 tokens: the table and column names plus three sample rows each. Curation therefore never scales with the size of the source. A SQLite file is not rebuilt row by row at all, it is adopted whole as the payload.
What the agent does
Find the dataset
The sample rows are in the body, which is what makes a spreadsheet findable by what it contains rather than only by its filename. A region name typed into sniff reaches a workbook nobody named after that region.
{"tool": "locate",
"args": {"query": "monthly sales by region 2026", "k": 3,
"type_filter": "dataset"}}Read the manual before writing SQL
A look at a dataset carries three things a SELECT cannot guess: the tables and columns that exist, three real rows, and the Notes a human wrote about what the numbers mean.
Call
{"tool": "look", "args": {"id": "finance/sales-2026"}}Digest, with the manual
{
"id": "finance/sales-2026",
"type": "dataset",
"title": "sales-2026",
"summary": "Booked revenue for 2026, one row per invoice line, with
region, product family and the booking month.",
"tags": ["finance", "revenue"],
"confidence": 1.0,
"updated": "2026-08-12",
"outline": ["Query manual", "Sample rows", "Notes"],
"edges_out": [],
"edges_in": [],
"query_manual": {
"tables": {
"monthly": ["month", "region", "product", "amount_cents", "currency"],
"regions": ["region", "manager", "opened_on"]
},
"example_queries": [
"SELECT region, SUM(amount_cents) FROM monthly GROUP BY region"
]
},
"sample_rows": {
"columns": ["month", "region", "product", "amount_cents", "currency"],
"rows": [
["2026-01", "EMEA", "Platform", 41200000, "EUR"],
["2026-01", "AMER", "Platform", 78940000, "USD"],
["2026-01", "EMEA", "Services", 9110000, "EUR"]
]
},
"notes": "amount_cents is minor units in the row's own currency: do
not sum across currencies without converting. Refunds are
negative rows, not deletions.",
"stats": { "body_tokens": 612, "degree": 0, "heat": 0.0 }
}Ask the question in SQL
Call
{"tool": "query", "args": {
"id": "finance/sales-2026",
"sql": "SELECT region, ROUND(SUM(amount_cents) / 100.0, 2) AS total
FROM monthly WHERE currency = 'EUR'
GROUP BY region ORDER BY total DESC"
}}Rows, 2000-token budget
{
"columns": ["region", "total"],
"rows": [
["EMEA", 4982340.15],
["APAC", 1204880.00]
],
"row_count": 2,
"limited": false,
"elapsed_ms": 3.41
}Read-only means read-only: a single statement, starting with SELECT or WITH, opened on a connection in mode=ro, with LIMIT 200 injected when the caller omitted one and a timeout on the progress handler. The guards answer before SQLite does, and they explain themselves:
# A statement that is not a read is refused by the guard, not by SQLite:
{"tool": "query", "args": {"id": "finance/sales-2026",
"sql": "DELETE FROM monthly"}}
→ {"error": {"code": "E_QUERY_FORBIDDEN",
"message": "statement must start with SELECT or WITH"}}
# A name that does not exist gets the list, so the next attempt is right:
{"tool": "query", "args": {"id": "finance/sales-2026",
"sql": "SELECT * FROM sales"}}
→ {"error": {"code": "E_QUERY_INVALID",
"message": "SQL error: no such table: sales",
"hint": "Tables in this dataset: monthly, regions."}}
# Writing a row is a different primitive and a different capability:
{"tool": "tend", "args": {"id": "finance/sales-2026",
"sql": "UPDATE monthly SET amount_cents = 0"}}
→ {"error": {"code": "E_QUERY_FORBIDDEN",
"message": "UPDATE without WHERE is not allowed (mass-wipe guard)",
"hint": "Target rows explicitly; full rewrites are the
Gardener's job."}}Teaching the dataset what it means
Column semantics are the one thing no machine can infer, and they are also what makes generated SQL wrong in ways that look right. A ## Notes section travels with the dataset everywhere it is read: it rides in the digest, and it rides in a harvest bundle even when the question's terms did not match it.
{"tool": "graft", "args": {
"id": "finance/sales-2026",
"patch": {
"append_section": {
"header": "Notes",
"body": "amount_cents is minor units in the row's own currency: do
not sum across currencies without converting. Refunds are
negative rows, not deletions. The 2026-03 EMEA figure was
restated on 2026-05-11."
}
}
}}Prose near a number is not the number
A dataset's figures live in its payload, and a retrieval sweep returns the node's prose. Asked for a total, a model handed only the prose will happily quote a nearby target note and cite it: fully faithful to a node, and wrong about the forest. That is why the answer composite tells the model, per call, that any dataset in the bundle has to be queried rather than read.
The whole thing in one call
On a Station with a model bound to the answer role, the retrieval and the reasoning collapse into one request. Retrieval still runs inside the asker's scope first, so binding a model never widens access.
POST /v1/forests/revenue/answer
curl -sX POST https://station.example.com/v1/forests/revenue/answer \
-H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"question": "which region led EUR revenue in 2026?", "k": 3}'200
{
"answer": "EMEA led EUR-denominated revenue in 2026 …",
"model": "qwen3-30b-a3b",
"model_ms": 2104.8,
"usage": { "prompt": 3180, "completion": 214, "calls": 1 },
"evidence": ["finance/sales-2026"],
"sources": [
{ "id": "finance/sales-2026", "title": "sales-2026",
"summary": "Booked revenue for 2026, one row per invoice line…",
"type": "dataset" }
]
}