Skip to content
MonkeyLLMDocs

Connect

Embed the engine

MonkeyLLM ships as a plain Python package with no host, no server and no UI attached. You can import it and call the primitives directly, spawn it as an MCP server, or run the governed Station in front of it. This page is those three shapes, and the real code that goes in each.

On this page(19)

Three delivery shapes

The same engine, the same primitives, the same forest on disk. What changes is where the process boundary falls and whether there is an identity layer in front of it.

importMCPHTTP
Your codeagent, service, notebook
Vine objectin process, no network
vine servestdio or HTTP MCP
Stationkeys, grants, audit
The forestmarkdown under git
Three doors, one room. A client written against vine serve works against a Station with nothing changed but the endpoint and a credential.
The three delivery shapes
ShapeHow you reach itIdentityReach for it when
Library, in processfrom monkeyllm import VineNone. The Vine object is unscoped and holds the filesystem authority your process holds.Your agent and the forest live in the same process, and you want the primitives as method calls with no serialisation in between.
stdio MCP servervine serve --forest ./brainNone beyond the filesystem the child process can see.The agent runtime is on the same machine and already speaks MCP: an IDE integration, a local coding agent, a desktop assistant.
HTTP StationPOST /v1/forests/… and /mcp/API keys, principals, capabilities, per-branch grants, an audit log.More than one person or agent touches the forest, or the forest is not on the caller's disk.

One writer per forest

A writable Vine takes an exclusive lock (.vine.lock at the forest root) on construction. A Station serving that same folder writable already holds it, so an in-process Vine("…", writable=True) beside it raises E_LOCKED. Readers never take the lock: pass writable=False and any number of processes can read the same forest at once.

Install the package

The engine has three runtime dependencies (mcp, pydantic, pyyaml) and needs Python 3.11 or newer. There is no database to provision and no index to build before the first call: the files are the database.

git clone https://github.com/JimmyWesley/MonkeyLLM.git
cd MonkeyLLM

# The engine: the monkeyllm package plus the vine CLI.
# Apache-2.0, Python >= 3.11, three runtime dependencies.
pip install -e .

python -c "import monkeyllm; print(monkeyllm.__version__)"
# 0.1.0

Open a forest in Python

A forest is a directory. Creating one writes the master index, the dialect that declares which node and edge types are legal, a .gitignore that keeps binaries out of history, and the first commit.

pythoncreate.py
from monkeyllm.forest import init_forest

# Creates the A.5 skeleton (master index, dialect, .gitignore) and the
# embedded git repository with its first commit. Idempotent it is not:
# it refuses to overwrite an existing _index.md.
info = init_forest("./brain", title="Product brain")
print(info)
# {'root': '/abs/path/brain', 'title': 'Product brain', 'commit': 'a1b2c3d…'}

From then on the whole engine rides one object. Opening it auto-indexes the forest when the catalog is empty, so a folder restored from a snapshot or checked out from git is usable on the first call.

pythonopen.py
from monkeyllm import Vine, VineError

# The context manager is the lifecycle: __exit__ releases the writer lock
# and closes the SQLite handles. A forest left locked by a crashed process
# raises E_LOCKED on the next open, and the fix is to delete .vine.lock.
with Vine("./brain", writable=True, session="setup") as vine:
    vine.warm()          # pays SQLite's wake-up before the first real call
    print(vine.canopy_status)
    # {'state': 'no-embedder', 'active': False, 'index_model': None,
    #  'query_model': None, 'vectors': 0, 'stale': 0}

Vine options

Vine(...) constructor

NameTypeRequiredDefaultDescription
rootstr | PathrequirednoneThe forest directory. It must already be a forest: a folder carrying _index.md and _meta/schema.md. Use init_forest() to create one.
writablebooloptionalTrueAcquires the forest writer lock (.vine.lock at the root) on construction. Pass False for a reader: no lock, plant, graft and tend refuse.
sessionstr | NoneoptionalNoneNames the trace session. Every read writes an event to _derived/traces/, and heat is deposited per session, so one hunt per session keeps the pheromone attributable.
alphafloatoptional0.3How strongly pheromone reweights ranking. The score of a result is its lexical strength multiplied by (1 + alpha * heat).
embedderobject | NoneoptionalNoneThe optional vector layer. Anything with the LlamaCppEmbedder shape; build one from the environment with embedder_from_env(). Absent, everything stays BM25 only.
betafloatoptional1.0Weight of the semantic signal when the frontier is ranked toward a goal, the Gauntlet in the spec. Only consulted when a usable vector layer is attached.
hybrid_locatebooloptionalFalseFuses vector hits into locate with reciprocal rank fusion. Off by design: measurement showed fusing a dense ranker into an already correct BM25 entry search degrades it. It is a public attribute, so a host can flip it per call.

Plant, then read it back

plant is atomic: the node file, the parent index entry and the catalog row land together, in one git commit, or none of them do. A node id is its path relative to the forest root without the extension, and it is immutable.

pythonwrite.py
with Vine("./brain") as vine:
    vine.plant({
        "id": "decisions/_index",
        "type": "branch",
        "parent": "_index",
        "title": "Decisions",
        "summary": "Every architectural decision we committed to, dated.",
    })

    result = vine.plant({
        "id": "decisions/postgres-over-mongo",
        "type": "event",
        "parent": "decisions/_index",
        "title": "We picked Postgres over Mongo",
        "summary": "2026-03: relational won on the reporting workload; "
                   "the write path was never the bottleneck.",
        "tags": ["database", "architecture"],
        "links": [{"rel": "related-to", "target": "decisions/_index"}],
        "body": "# We picked Postgres over Mongo\n\n"
                "## Context\n\nReporting needed joins across six entities.\n\n"
                "## Decision\n\nPostgres 16, one primary, logical replicas.",
    })
    print(result)
    # {'id': 'decisions/postgres-over-mongo',
    #  'commit': '9f4c1ab…',
    #  'trail': ['_index', 'decisions/_index']}

Reading is the other half, and it is deliberately several small calls rather than one big one. Each carries its own token budget, so an agent can afford to look before it reads.

pythonread.py
with Vine("./brain", writable=False, session="q-142") as vine:
    hits = vine.locate("why did we choose our database", k=3)
    for hit in hits["results"]:
        print(hit["id"], hit["score"], hit["summary"])

    digest = vine.look("decisions/postgres-over-mongo")
    print(digest["outline"])      # ['Context', 'Decision']
    print(digest["stats"])        # {'body_tokens': …, 'degree': 1, 'heat': …}

    body = vine.pick("decisions/postgres-over-mongo", section="Decision")
    print(body["body"])

    # Closes the hunt: heat on the winning trail, plus the run's metrics.
    print(vine.close_session(True, ["decisions/postgres-over-mongo"]))

Sessions are how heat stays honest

Every read deposits pheromone on the nodes it touched, and close_session(success, answer_nodes) reinforces the trail that actually answered. Give each hunt its own session string and the corpus learns which paths pay off. Skip it and nothing breaks, you simply never get the reinforcement.

Datasets in process

Tabular data does not become prose. A dataset node is a passport beside a real SQLite file, and the schema you hand plant is declarative data that the engine turns into CREATE TABLE statements. An agent never writes DDL.

pythondataset.py
with Vine("./brain") as vine:
    vine.plant({
        "id": "finance/ledger",
        "type": "dataset",
        "parent": "finance/_index",
        "title": "2026 ledger",
        "summary": "Every booked invoice of 2026, one row per line item.",
        # The schema is DATA, never DDL. Vine generates the CREATE TABLEs,
        # births the SQLite payload, hashes it into the frontmatter and
        # writes a "## Query manual" section into the body.
        "schema": {
            "invoices": {
                "columns": {
                    "id": "INTEGER",
                    "issued_on": "TEXT",
                    "customer": "TEXT",
                    "amount_cents": "INTEGER",
                },
                "primary_key": ["id"],
            }
        },
        "rows": {
            "invoices": [
                [1, "2026-01-14", "Northwind", 412000],
                [2, "2026-01-27", "Initech", 89900],
            ]
        },
    })
pythondataset.py
with Vine("./brain") as vine:
    # look() on a dataset carries the manual, three sample rows, and the
    # "## Notes" a person wrote about what the columns mean.
    digest = vine.look("finance/ledger")
    print(digest["query_manual"])
    # {'tables': {'invoices': ['id', 'issued_on', 'customer', 'amount_cents']},
    #  'example_queries': [...]}

    # Read-only SQL. SELECT or WITH only, one statement, LIMIT 200 injected
    # when absent, 2000-token budget on the payload.
    rows = vine.query("finance/ledger", """
        SELECT customer, SUM(amount_cents) / 100.0 AS total
        FROM invoices GROUP BY customer ORDER BY total DESC
    """)
    print(rows)
    # {'columns': ['customer', 'total'], 'rows': [['Northwind', 4120.0], …],
    #  'row_count': 2, 'limited': False, 'elapsed_ms': 0.31}

    # Writes go through tend: one INSERT, UPDATE or DELETE, WHERE mandatory
    # on the last two. It refreshes payload_hash and commits the .md, never
    # the binary.
    print(vine.tend("finance/ledger",
                    "INSERT INTO invoices VALUES (3, '2026-02-02', 'Umbrella', 250000)"))
    # {'id': 'finance/ledger', 'rows_affected': 1,
    #  'payload_hash': '…', 'commit': '…', 'elapsed_ms': 1.9}

One writer, and the lock

  • writable=True acquires .vine.lock at construction and releases it on close(). A process that died without closing leaves the file behind, and the next open fails with E_LOCKED. Deleting the stale lock is the documented repair.
  • writable=False takes no lock. Any number of readers can share a forest, including while a writer holds it.
  • warm() pays SQLite's cold start before the first real call, touching storage only. It never runs a primitive, because a warm-up that called locate would forge pheromone nobody deposited.
  • reindex() rebuilds the derived catalog from the files. The files are the truth; everything under _derived/ is disposable by definition.

Replace the vector store

The usual retrieval layer in an agent is one function: question in, blob of context out. Swapping MonkeyLLM in has two versions, and which one you want depends on whether your model gets to ask a second question.

The one-line swap: harvest

harvest is the composite for bring-your-own-model clients: a deterministic locate plus sniff sweep, fused by reciprocal rank, returning the nodes themselves. Zero LLM calls happen inside it, and no embedding server is required.

def retrieve(question: str) -> str:
    docs = store.similarity_search(question, k=4)
    return "\n\n".join(d.page_content for d in docs)

context = retrieve(question)
answer = llm(f"Context:\n{context}\n\nQuestion: {question}")

# What you do not have: where a chunk came from, whether the four
# chunks are the four that matter, or any way for the model to
# ask a second question of the corpus.

What comes back, for a 4000-token budget over the whole bundle:

jsonharvest(...)
{
  "query": "why did we choose our database",
  "terms": ["choose", "database"],
  "results": [
    {
      "id": "decisions/postgres-over-mongo",
      "title": "We picked Postgres over Mongo",
      "type": "event",
      "trail": ["_index", "decisions/_index"],
      "summary": "2026-03: relational won on the reporting workload; …",
      "score": 0.0328,
      "found_by": ["locate", "sniff"],
      "matches": [
        {
          "section": "Decision",
          "line": 9,
          "snippet": "Postgres 16, one primary, logical replicas."
        }
      ],
      "content": [
        { "section": null, "body": "# We picked Postgres…", "body_tokens": 74 }
      ]
    }
  ],
  "truncated": false
}

The differences that matter to an agent are the ones a chunk list cannot express: id is citable and stable, trail says where in the tree this came from, found_by says whether the metadata or the body matched, and matches quotes the exact line, so a model that must not paraphrase has something to copy verbatim.

The other version gives the model the primitives and lets it spend its own turns. This is what the benchmark agent does, and it is the shape that answers questions no single retrieval could: the ones where the second hop is only knowable after the first.

pythonnavigator.py
"""A navigator loop: the model drives, the forest answers.

This is the shape the reference agent in examples/demo/run_demo.py uses.
The point is that the model never receives a dump: it receives a map, and
spends its own turns deciding where to go.
"""
import json

from monkeyllm import Vine, VineError

SYSTEM = """You navigate a knowledge forest. Reply with ONE JSON object:
- {"tool": "locate", "args": {"query": "...", "k": 5}}   entry points
- {"tool": "sniff",  "args": {"terms": ["..."]}}          literal grep on bodies
- {"tool": "look",   "args": {"id": "..."}}               cheap digest of a node
- {"tool": "move",   "args": {"id": "...", "rel": null}}  neighbours
- {"tool": "pick",   "args": {"id": "...", "section": null}}  the body
- {"tool": "query",  "args": {"id": "...", "sql": "SELECT ..."}}  datasets
- {"tool": "answer", "args": {"text": "...", "answer_nodes": ["id"]}}

Rules: "truncated": true means the list was cut by budget, so ask
narrower rather than concluding something is absent. Aggregates over a
dataset exist only through query. Cite the ids you actually opened."""


def navigate(vine: Vine, question: str, llm, max_steps: int = 14) -> dict:
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": json.dumps(vine.look("_index"))},
        {"role": "user", "content": question},
    ]
    for _ in range(max_steps):
        step = json.loads(llm(messages))
        tool, args = step["tool"], step.get("args", {})

        if tool == "answer":
            # Reinforces the trail that worked, so the next hunt for a
            # neighbouring question starts warmer.
            vine.close_session(True, args.get("answer_nodes", []))
            return step

        try:
            result = getattr(vine, tool)(**args)
        except VineError as e:
            # The engine's envelope is written for the caller: the hint
            # tells the model what to do instead. Hand it back verbatim.
            result = e.to_dict()
        except (AttributeError, TypeError) as e:
            result = {"error": {"code": "E_SCHEMA", "message": str(e)}}

        messages.append({"role": "assistant", "content": json.dumps(step)})
        messages.append({"role": "user", "content": json.dumps(result)})

    vine.close_session(False, [])
    return {"tool": "answer", "args": {"text": "out of steps", "answer_nodes": []}}

Errors are part of the protocol

Every refusal is the same envelope: code, message and usually a hint written for whoever called. Handing that dictionary straight back to the model is not a fallback, it is the design. A model told "forbidden keyword: DROP, tend accepts INSERT, UPDATE or DELETE only" corrects itself on the next turn.

Persistent agent memory

The forest outlives the process. Nothing is held between sessions because there is nothing to hold: the state is a folder, under git, that any later run reopens. That makes MonkeyLLM usable as an agent's long-term memory without a memory service, a vector database or a serialisation format of your own.

pythonmemory.py
"""Agent memory that survives the process.

Session 1 plants what it learned. Session 9, tomorrow, on another machine,
against the same folder, finds it. Nothing is held in RAM between them:
the forest on disk is the state.
"""
import datetime as dt
import re

from monkeyllm import Vine

MEMORY_BRANCH = "memory/_index"


def ensure_memory(vine: Vine) -> None:
    """Idempotent: plant refuses a duplicate id, which is the check."""
    if not vine.forest.exists(MEMORY_BRANCH):
        vine.plant({
            "id": MEMORY_BRANCH, "type": "branch", "parent": "_index",
            "title": "Memory",
            "summary": "What agents learned in earlier sessions, one node each.",
        })


def remember(vine: Vine, title: str, summary: str, body: str,
             tags: list[str] | None = None) -> dict:
    """One durable fact becomes one node. The summary is not decoration:
    it is the text locate() ranks, so it is how this will be found again."""
    slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:63]
    return vine.plant({
        "id": f"memory/{slug}",
        "type": "note",
        "parent": MEMORY_BRANCH,
        "title": title,
        "summary": summary,
        "tags": tags or [],
        "source": "agent",
        "body": f"# {title}\n\n{body}\n\n_Learned {dt.date.today().isoformat()}._",
    })


def recall(vine: Vine, question: str, k: int = 3) -> list[dict]:
    """Search the whole forest, not only the memory branch: a fact planted
    last week and a document ingested this morning are both just nodes."""
    hits = vine.locate(question, k=k)
    return [vine.pick(hit["id"]) for hit in hits["results"]]
pythonsessions.py
# --- session 1, Tuesday ---------------------------------------------
with Vine("./brain", session="tue-a") as vine:
    ensure_memory(vine)
    remember(
        vine,
        title="Northwind bills on net-45",
        summary="Northwind negotiated net-45 payment terms in March 2026, "
                "against our standard net-30.",
        body="Agreed with their AP lead during the renewal call. "
             "Applies to every invoice issued from 2026-04-01.",
        tags=["billing", "northwind"],
    )

# --- session 9, Thursday, a different process ------------------------
with Vine("./brain", writable=False, session="thu-c") as vine:
    for note in recall(vine, "what payment terms does Northwind have?"):
        print(note["id"], "->", note["body"])
    # memory/northwind-bills-on-net-45 -> # Northwind bills on net-45 …

Three properties come free with the file layout:

  • It is inspectable. A memory the agent got wrong is a markdown file a human edits, and the correction and the agent write land in the same git log.
  • It is attributable. Each commit names what happened: plant(memory/…): title [source=agent]. Under a Station, the acting principal is stamped into the commit as well.
  • It gets better with use. Recall deposits heat on what it found, so the memories that keep answering questions keep rising in locate.

Write the summary for the search

The summary field is the text that gets ranked and the text a neighbouring node shows about this one. "Northwind negotiated net-45 in March 2026" will be found; "notes from the call" will not. It is the single highest-leverage field in the whole passport.

Serve it instead of importing it

When the agent is not your Python process, the same engine goes behind a transport. Nothing about the primitives changes: the MCP tools are the primitives, argument for argument.

# One forest, one child process, no network, no accounts.
# This is what an IDE or a local agent runtime spawns.
vine serve --forest ./brain

# Read-only, for an agent you do not want writing:
vine serve --forest ./brain --readonly

# Many forests from one server. Every tool then requires forest=<id>,
# and every subdirectory holding an _index.md is servable.
vine serve --root ./forests

In registry mode (--root), the server hosts every subdirectory that holds an _index.md, opens each lazily on first touch, and requires forest=<id> on every tool. Agents should call the forests() tool first to learn what they may use.

A Station client in TypeScript

Nothing about the HTTP surface is Python-shaped. One route covers every primitive, the body is the arguments, and the reply is the primitive payload or the error envelope.

typescriptstation.ts
/**
 * A Station client in ~40 lines. Every primitive is the same route shape:
 * POST /v1/forests/{forest}/{primitive} with the arguments as the JSON body.
 * There is no SDK to install and no privileged side channel: this is the
 * surface the Studio console itself calls.
 */
type VineError = {
  error: { code: string; message: string; hint?: string };
};

export class Station {
  constructor(
    private readonly origin: string,
    private readonly key: string,
    private readonly forest: string,
  ) {}

  private async call<T>(primitive: string, body: unknown): Promise<T> {
    const res = await fetch(
      `${this.origin}/v1/forests/${this.forest}/${primitive}`,
      {
        method: 'POST',
        headers: {
          authorization: `Bearer ${this.key}`,
          'content-type': 'application/json',
        },
        body: JSON.stringify(body),
      },
    );
    const json = (await res.json()) as T | VineError;
    if (!res.ok && 'error' in (json as VineError)) {
      const { code, message, hint } = (json as VineError).error;
      // The hint is written for the caller. Surfacing it is the difference
      // between "403" and "this key holds read, query is what you need".
      throw new Error(hint ? `${code}: ${message} (${hint})` : `${code}: ${message}`);
    }
    return json as T;
  }

  harvest(query: string, k = 3) {
    return this.call<{ results: unknown[] }>('harvest', { query, k });
  }

  answer(question: string, k = 3) {
    return this.call<{ answer: string; evidence: string[] }>('answer', {
      question,
      k,
    });
  }

  look(id: string) {
    return this.call<Record<string, unknown>>('look', { id });
  }

  query(id: string, sql: string) {
    return this.call<{ columns: string[]; rows: unknown[][] }>('query', {
      id,
      sql,
    });
  }
}

Call

typescript
const station = new Station(
  'https://station.example.com',
  process.env.MONKEYLLM_KEY!,
  'handbook',
);

const grounded = await station.answer('what is our expense policy?');
console.log(grounded.answer);
console.log('cited:', grounded.evidence);
// cited: [ 'policies/expenses', 'policies/receipts' ]

Reply

json
{
  "answer": "Expenses are reimbursed within 30 days …",
  "model": "openai/gpt-oss-120b",
  "model_ms": 1840.2,
  "evidence": ["policies/expenses", "policies/receipts"],
  "sources": [
    { "id": "policies/expenses", "title": "Expense policy",
      "summary": "…", "type": "document" }
  ]
}

A refusal is the same shape whatever went wrong, mapped onto an HTTP status. Show the hint: it is written for the caller, not for a log.

json403 Forbidden
{
  "error": {
    "code": "E_FORBIDDEN",
    "message": "'query' requires the 'query' capability",
    "hint": "This principal holds: ['read']."
  }
}

Out of scope reads as absent

A node a key may not see answers E_NOT_FOUND, byte for byte identical to a node that does not exist. Do not write client code that treats a 404 as proof of absence when the key is scoped: it is proof of absence for this key, which is the whole point.

Extension points

The engine is extended at the edges, never in the middle. What feeds data in, what model answers, and where payloads live are all pluggable. The primitives themselves are the fixed contract every client talks to.

Extension seams
SeamHow you plug in
Chat modelAny OpenAI-compatible /v1/chat/completions, by environment: MONKEYLLM_LLM_ENDPOINT, _MODEL, _API_KEY, _MAX_TOKENS, _REASONING. Consumed identically by curation and by an agent driving the loop. There is no prompting plugin, by design, so behaviour stays reproducible across providers.
EmbedderAny OpenAI-compatible /v1/embeddings, by MONKEYLLM_EMBED_ENDPOINT, _MODEL, _API_KEY. Optional in the strict sense: with no index and no embedder, locate stays BM25 only and every contract is unchanged.
ConvertersPer file extension, resolved in one order: forest command hooks, then monkeyllm.converters entry points, then the built-ins. The first that claims an extension wins, so you can override a built-in without forking.
Curation hookson_curate callables under the monkeyllm.hooks entry-point group. They receive the frontmatter draft and return it, after the model curator and before plant.
Payload fetchersfile:// and s3:// ship in the FETCHERS registry (fetch.py), with MONKEYLLM_S3_ENDPOINT for MinIO or R2 style hosts. Downloads land in a hash-validated cache and a tampered one is refused, never served. Adding a scheme means editing that registry in tree: it is short and security sensitive, so it is deliberately not open to arbitrary installed packages.
MCP clientsThe intended integration surface for a new UI or bot. Per the spec, UIs and bots are MCP or library clients, not plugins.

A converter without a dependency

The lightest extension point needs no package at all: name a command in the forest's own config. This is how PDFs get in, since a good extractor is usually a heavyweight or copyleft dependency the engine will not force on you.

yamlbrain/_meta/gardener.yaml
# _meta/gardener.yaml, inside the forest
converters:
  # {input} and {output} are substituted. The command must write markdown
  # to {output} (or print it), and exit 0. A "# Title" first line becomes
  # the node title. Non-zero exit aborts that ONE file, never the batch.
  ".pdf": '"/usr/local/bin/pdf-to-md" "{input}" "{output}"'

curation:
  default_tags: ["internal"]
  directives: |
    Prefer a formal tone. Flag anything mentioning PII.

A converter as an installed package

tomlpyproject.toml
# your-plugin/pyproject.toml
[project.entry-points."monkeyllm.converters"]
pdf = "my_monkeyllm_plugin:PdfConverter"

[project.entry-points."monkeyllm.hooks"]
on_curate = "my_monkeyllm_plugin:add_compliance_tag"
pythonmy_monkeyllm_plugin/__init__.py
# my_monkeyllm_plugin/__init__.py
from pathlib import Path


class PdfConverter:
    """The Converter protocol is one method returning a Conversion:
    kind ("markdown" or "dataset"), title, and either markdown, or
    schema + rows for a converter that discovered tabular data."""

    def convert(self, path: Path):
        from monkeyllm.gardener import Conversion

        text = my_extractor(path)
        return Conversion(kind="markdown", title=path.stem, markdown=text)


def add_compliance_tag(draft: dict) -> dict:
    """An on_curate hook: deterministic enrichment that runs after the LLM
    curator (or instead of it, when curation is off) and before plant.
    It receives the frontmatter draft and returns it."""
    draft.setdefault("tags", []).append("compliance")
    return draft

A broken plugin is skipped rather than fatal, and a hook that raises has its error recorded in the ingest report without aborting the batch: already-planted nodes stay planted and later files keep processing.

Driving the Gardener yourself

Ingestion is not CLI-only. The same object the vine adopt command builds is importable, which is how you put a document pipeline inside your own service.

pythoningest.py
from monkeyllm import Vine
from monkeyllm.gardener import Gardener, discover_hooks

with Vine("./brain") as vine:
    gardener = Gardener(vine, hooks=discover_hooks())

    report = gardener.adopt("./case-files", dest="cases")
    for key in ("planted", "branches", "updated", "unchanged",
                "stale", "unsupported", "errors"):
        if report.get(key):
            print(key, len(report[key]))

    # Later, after the source folder moved on: a hash diff, not a re-import.
    print(gardener.sync("./case-files"))

Boundaries that do not move

These are contract, not implementation detail. A change to any of them is a spec revision first and code second, so you can build against them.

  • Primitive semantics and their token budgets: look 500, move 600, locate, scan and sniff 800 each. Truncation is always explicit, never a silent cut.
  • The locate and sniff split, metadata search against body search, never merged into one call.
  • tend stays DML only, and the declarative schema on plant stays the only path to a new table. No ALTER for agents, ever.
  • Edge proposals target only existing nodes drawn from a closed candidate list, so a hallucinated link target is structurally impossible to plant.
  • Binaries never enter forest git. Payloads are referenced by payload_hash, not committed.

Where to go next

The engine source is the reference of last resort, and it is meant to be read: the repository carries the normative spec beside the implementation.

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