Skip to content
MonkeyLLMDocs

Build

Agent flows

Four complete scenarios, each one a real goal followed all the way through: the setup, the commands, the calls the agent actually makes, the payloads that come back, and what the person asking finally sees.

On this page(18)

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 answer composite. Adopting a folder without one still produces a navigable forest, with factual template summaries in place of written ones.
  • The JSON blocks marked tool are MCP tool calls, which is what an agent runtime emits. The same arguments go in the body of POST /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.

sniffedges_inmove
The questiontwo facts, two documents
amendment-04found by sniff
Northwindentity node
qa/report-2026-02the second fact
Nothing links the amendment to the QA report directly. The path runs through the entity both documents mention.

Setup

  1. 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. 2

    Read the report, because it tells you what did not land

    textadopt output
    planted (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. 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

json
{"tool": "look", "args": {"id": "_index"}}

Digest, 500-token budget

json
{
  "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

json
{"tool": "sniff", "args": {"terms": ["delivery window"], "k": 5}}

Node, section, exact line

json
{
  "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

json
{"tool": "look", "args": {"id": "contracts/amendment-04"}}

Digest

json
{
  "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

json
{"tool": "move", "args": {"id": "entities/northwind-logistics"}}

Neighbours, hottest first

json
{
  "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

json
{"tool": "pick",
 "args": {"id": "qa/report-2026-02", "section": "Findings"}}

One section

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

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

Bearergrantsscoped
Agentholds mk_…
Registrykey → principal
Policycaps, allow, deny
The forestfiltered view
Audit logwho read what
Every surface reaches a forest through the same scoped seam. There is no privileged side channel, and the console calls exactly these routes.

Setup

  1. 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. 2

    The forest, as it sits on disk

    text/forests/handbook
    handbook/
    ├── _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.md

    Scope 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 the product subtree. Prefixes compare with a trailing slash, so a grant on product/ cannot accidentally swallow a product-secret/ beside it.

  3. 3

    Grant, and mint the key in the same call

    POST /v1/admin/grant

    bash
    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/", "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. 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

bash
curl -s https://station.example.com/v1/me \
  -H "Authorization: Bearer mk_7Qd1…"

200

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

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.

bashshell
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

bash
curl -s "https://station.example.com/v1/admin/audit?limit=5&principal=support-bot" \
  -H "Authorization: Bearer $OWNER_KEY"

200

json
{
  "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:

textshell
$ 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-sync

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

plantlocatecorrects
Mondaysession A
The forestmarkdown, git
Thursdaysession B, new process
A persongit log, an editor
Nothing is held between sessions, because there is nothing to hold. The disk is the state, and it is readable by both parties.

Setup

  1. 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."})
    PY

    A 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. 2

    Register it with the agent runtime

    {
      "mcpServers": {
        "monkeyllm": {
          "command": "vine",
          "args": ["serve", "--forest", "/Users/you/brain"]
        }
      }
    }

    The stdio server writes as whoever runs it. Over a Station the same tools are gated by capability: plant and graft need write, 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

json
{"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

json
{
  "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

json
{"tool": "locate",
 "args": {"query": "what payment terms does Northwind have", "k": 3}}

Ranked entry points

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

json
{"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:

textshell
$ 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.

adoptpayloadSQL
sales-2026.xlsxone sheet per table
dataset nodepassport + manual
SQLite payloadnever in git
The agentlook, then query
The passport is committed and the binary is not: git holds the map and the payload_hash, never the megabytes.

Setup

  1. 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 finance
    textadopt output
    planted (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. 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.

json
{"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

json
{"tool": "look", "args": {"id": "finance/sales-2026"}}

Digest, with the manual

json
{
  "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

json
{"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

json
{
  "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:

textrefusals
# 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.

json
{"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

bash
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

json
{
  "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" }
  ]
}

Where to go next

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