Two servers, one contract
There are two MCP servers in the project, and they are deliberately contract-compatible. An agent that works against a forest on your own disk works against a Station-served forest with no change beyond the endpoint and a credential.
vine serve | Station /mcp | |
|---|---|---|
| Server name | vine | monkeyllm-station |
| Transports | stdio, streamable HTTP | streamable HTTP |
| Authentication | None. Whoever runs the process holds the forest | An API key per request, resolved to a principal and a mask |
The forest argument | Optional in single-forest mode, required with --root | Required on every tool |
| Extra tools | close_session | answer, ingest |
| Audit | Git history only | Git history plus a host access log per call |
The rest of this page documents the Station surface, because that is what a shared deployment exposes. Where the local server differs, it is called out.
Transports
stdio
The engine speaks stdio by default: the client launches vine serve as a subprocess and talks over its pipes. No port, no network, no accounts. For a forest on your own machine this is the whole deployment.
# One forest, stdio. The default, and the whole deployment.
vine serve --forest ./brain
# Read-only, for an agent you do not want writing yet.
vine serve --forest ./brain --readonly
# Registry mode: every subdirectory with an _index.md is servable, opened
# lazily on first touch. Tools then require forest=<id>.
vine serve --root /forestsIn registry mode every subdirectory of --root that contains an _index.md is servable and opened lazily on first touch, auto-indexing itself if its catalog is empty. Tools then require forest=<id>, and forests() lists what is available.
Streamable HTTP
Over the network, both servers speak MCP streamable HTTP. The Station mounts its surface at /mcp and runs it in stateless mode with JSON responses: each HTTP request is handled in its own task, which is what lets the key that authenticated a request be the key the tool body reads. With sessions enabled, a tool call could be dispatched to a task created during an earlier request, so statelessness here is a correctness choice, not a performance one.
# The engine over the network, no host, no accounts.
vine serve --forest ./brain --transport http --host 127.0.0.1 --port 8000
# The Station: identity, policy and audit around the same engine.
station serve --root /forests --registry /registry/station.db \
--host 0.0.0.0 --port 8800 --writableMind the trailing slash
The Station's MCP endpoint is https://station.example.com/mcp/. The inner app is served at its own root and mounted under /mcp by the Station, so register the URL exactly as written, with the trailing slash.
Verify the server
Do this before touching a client config. Almost every "my agent shows no tools" report is a server that never started, a path that did not resolve, or a key with no grants, and all three are one curl away from being ruled out. Run the HTTP transport for the check even if you intend to deploy over stdio: a stdio server has no endpoint to probe, because the client is supposed to spawn it.
# 1. Start it in a terminal you can watch.
vine serve --forest /absolute/path/to/brain --transport http --port 8000
# 2. The engine's transport is session-based and answers as SSE, so the
# handshake comes first. The session id comes back as a header.
curl -isX POST http://127.0.0.1:8000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"curl","version":"0"}}}' | grep -i mcp-session-id
# 3. Ask it what it can do, quoting that session id.
curl -sX POST http://127.0.0.1:8000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H "mcp-session-id: <the id from step 2>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'# The Station is alive and knows whether it has been claimed:
curl -s https://station.example.com/v1/health
# The key resolves, and to whom, with which capabilities:
curl -s https://station.example.com/v1/me -H "Authorization: Bearer $KEY"
# The Station's /mcp mount is stateless with JSON responses, so one
# request is the whole smoke test: no handshake, no session id.
curl -sX POST https://station.example.com/mcp/ \
-H "Authorization: Bearer $KEY" \
-H 'content-type: application/json' -H 'accept: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'A tool list comes back with look, locate, sniff, move, pick, scan, harvest and the rest, filtered by what the key may do. That is the whole proof: the process is up, the forest opened, and the credential resolves. Only then wire the client.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{ "name": "forests", "description": "…" },
{ "name": "locate", "description": "…" },
{ "name": "look", "description": "…" },
{ "name": "move", "description": "…" },
{ "name": "pick", "description": "…" },
{ "name": "scan", "description": "…" },
{ "name": "sniff", "description": "…" },
{ "name": "harvest", "description": "…" }
]
}
}When no tools show up
vine: command not found, from the client only. A GUI client launched from the dock does not inherit your shellPATH, so avineinstalled in a virtualenv is invisible to it even though your terminal finds it. Put the absolute interpreter path in the config:/path/to/.venv/bin/vine.- A relative forest path in the config.
--forest ./brainresolves against the client's working directory, which is not yours and is rarely documented. Always write an absolute path inmcp.json, which is why every stdio example on this page does. - The server exits immediately with
is not a forest (no _meta/schema.md). The path exists but was nevervine init-ed, or it points one level above or below the real root. E_LOCKED, or a server that starts and answers nothing. A forest takes one writer. If another process (a second client, a leftovervine serve) holds it, start this one--readonlyor stop the other.- Connected, but the tool list is short or empty. That is authorisation, not transport: the key's grants decide which tools are advertised. Check them with
GET /v1/meon the Station. - Nothing in the terminal. A stdio server writes its diagnostics to the client, not to you. Every MCP client keeps its own log: Claude Code under
~/.claude/logs/, most editors under their own extension log pane. Read that before rebuilding the config.
Connect an agent
Pair a key
Your agent needs a credential that is yours, not one an administrator has to mint. Pairing is that door: it is unauthenticated like login, takes your own username and password, and answers with an API key.
curl -sX POST https://station.example.com/v1/auth/pair \
-H 'content-type: application/json' \
-d '{"username": "jimmy", "password": "...", "label": "claude-code"}'{
"api_key": "mk_D5vN8hZq2WcJ1yLpXeT7bR0mKsA4gFuI6oQnV3rYtBd",
"principal": "jimmy",
"caps": ["ingest", "read"],
"expires_at": "2026-11-13T09:41:12+00:00"
}What makes a paired key safe to hand to a machine is that it can only narrow, never add:
- The mask is a ceiling. A paired key carries
{read, ingest}by default, and that set is also the maximum. Asking forwrite,tend,queryoradminis refused: those stay what an administrator mints deliberately. - Grants intersect the mask at the moment of use. The key's effective authority is your own grants filtered through the mask, computed live. A grant revoked after pairing is gone from the key immediately, and a paired key held by the owner is still refused every admin route.
- It always expires. 90 days by default, 365 at most. There is no unlimited, and the key is shown once: only its digest is stored.
The key lives where every key lives. An administrator can revoke it from the Access console at any time, and the revocation takes effect on the next call.
Claude Code
With the paired key in hand, the whole connection is one registration.
claude mcp add --transport http monkeyllm \
https://station.example.com/mcp/ \
--header "Authorization: Bearer mk_D5vN8hZq2WcJ1yLpXeT7bR0mKsA4gFuI6oQnV3rYtBd"claude mcp add monkeyllm -- vine serve --forest /absolute/path/to/brainFrom then on the forest's tools are in every session. Have the agent call forests() first: a scoped key has no access to the master index, so that call is how it learns which forests it may use and which roots to start from.
Any other MCP client
Nothing above is particular to Claude Code beyond the command. Any MCP-capable runtime connects with the same endpoint and the same key. Register it wherever your runtime configures MCP servers:
{
"mcpServers": {
"monkeyllm": {
"type": "http",
"url": "https://station.example.com/mcp/",
"headers": {
"Authorization": "Bearer mk_D5vN8hZq2WcJ1yLpXeT7bR0mKsA4gFuI6oQnV3rYtBd"
}
}
}
}{
"mcpServers": {
"monkeyllm": {
"command": "vine",
"args": ["serve", "--forest", "/absolute/path/to/brain"]
}
}
}The Station also accepts the key in an X-Api-Key header if your client finds that easier than a bearer token. Both are resolved at the same gate.
Allowed hosts
The MCP mount only answers requests whose host is on an allowlist. The default covers a local install (localhost, 127.0.0.1, both with and without port 8800). If you serve through a domain, name it.
# docker-compose.yml, or wherever the Station's environment lives
MONKEYLLM_STATION_ALLOWED_HOSTS=station.example.com,localhost:8800
# Skip the check entirely. Every request still needs a key.
MONKEYLLM_STATION_ALLOWED_HOSTS=*DNS-rebinding protection defends servers that trust the browser's ambient credentials. Every request here carries an API key an attacker cannot supply, which is why the deployment's own host list is the right control rather than a hardcoded one. Setting * disables the check and changes nothing about authentication.
The tools
The tools are the engine primitives plus the composites, each behind the capability it needs. forests answers to any valid key; everything else is gated. A capability the key does not hold is refused with E_FORBIDDEN naming the capability, never by hiding the tool: an agent that can see what it is missing can tell you.
| Tool | Needs | What it does |
|---|---|---|
forests | any key | Lists the forests this key may use, with capabilities and starting roots |
locate | read | Ranked entry points over curated metadata: where to drop in |
look | read | Cheap digest of one node: summary, edges, children, stats |
move | read | Neighbours of a node along typed edges |
pick | read | Reads the body, or one section of it |
scan | read | Filters a branch's nodes by metadata |
sniff | read | Literal search inside bodies: the facts summaries do not carry |
harvest | read | One-shot retrieval: ranked evidence with exact snippets, no hops |
answer | read | A grounded answer written by the model bound to the forest, with its evidence |
view | read | The image behind a media node, as image content your model reads directly |
query | query | Read-only SQL against a dataset node |
plant | write | Creates a node |
graft | write | Edits a node |
tend | tend | Single-statement dataset write |
ingest | ingest | Puts documents into the forest through the Gardener |
A reasonable mental model: answer and harvest are the one-shots, the locate / look / move / pick / scan / sniff family is navigation, query and tend are the dataset pair, and plant, graft and ingest are how the forest grows.
forests
Takes no arguments. Call it first, every session. It returns only the forests this key holds a grant on, and for each one the roots to start from, because a scoped key has no access to the master _index. When the key carries a capability mask, the caps reported are the masked ones: what the agent is told it may do is what the key can actually do.
{
"forests": [
{
"id": "handbook",
"caps": ["ingest", "read"],
"roots": ["policies/_index"]
}
]
}Navigation
Every tool below takes forest as its first argument on the Station surface. Full response shapes are in the REST reference: the tool returns exactly what the REST route returns, because it is the same dispatch.
locate(forest, query, k, scope, type_filter)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id from forests(). |
query | string | required | none | Free text, matched against curated metadata rather than bodies. |
k | integer | optional | 5 | How many entry points to return. |
scope | string | optional | "all" | all, branches or bananas. |
type_filter | string | optional | none | Keep only one node type. |
look(forest, id, fields)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
id | string | required | none | The node to digest. |
fields | string[] | optional | none | Return only these keys. Worth using on dataset nodes, where the query manual and sample rows each open the payload. |
move(forest, id, rel, direction)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
id | string | required | none | The node to move from. |
rel | string | optional | none | One relation, or the special value "children" to list a branch's physical children. |
direction | string | optional | "out" | out, in or both. |
pick(forest, id, section)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
id | string | required | none | The node to read. |
section | string | optional | none | A header from the node's outline. Bodies over 4000 tokens come back as the outline plus a hint to ask for one section. |
scan(forest, parent_id, filter, recursive, limit)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
parent_id | string | required | none | The branch to scan. |
filter | object | optional | none | Passport columns plus tags_any, updated_after, updated_before, created_after, min_confidence. |
recursive | boolean | optional | false | Walk the whole subtree. |
limit | integer | optional | 50 | Clamped to the range 1 to 50. |
sniff(forest, terms, scope, k)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
terms | string[] | required | none | One to 8 literal terms, at least 2 characters each. Exact codes, names and numbers. No regular expressions. |
scope | string | optional | none | A branch id narrows to that subtree; a leaf node id greps inside that node alone. |
k | integer | optional | 5 | Clamped to a maximum of 20. |
harvest and answer
These are the two one-shots, and the choice between them is about who does the reasoning.
harvestruns no model at all. It sweepslocateandsniff, fuses the rankings, and hands back the material itself with exact snippets and trails. Your agent reasons over it. Use it when the agent will do the thinking.answerruns the same sweep and then lets the model bound to the forest read it, returning a grounded answer with its evidence. Use it when the forest's answer is the answer, and you would rather not spend the agent's own context on the material.
harvest(forest, query, terms, k)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
query | string | required | none | The free-text question. |
terms | string[] | optional | none | Literal terms for the sniff half. Derived from the query when omitted. |
k | integer | optional | 3 | Clamped to at least 1 and at most the deployment cap, 5 by default. |
answer(forest, question, k, cache, reply_tokens)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
question | string | required | none | The question to answer. |
k | integer | optional | 3 | How many nodes the retrieval half brings back. |
cache | boolean | optional | true | A repeat may be served from the forest's answer store, labelled cached: true. Pass false to buy a fresh run, which replaces the stored one. |
reply_tokens | integer | optional | none | Bound the reply for this call, clamped to the range 64 to 4000. Absent, the forest binding decides. |
answer needs a bound model
answer is a composite, not a primitive: it needs a model bound to this forest's answer role. Without one it returns E_SCHEMA saying so. The model only ever sees material the key could already read, so binding a model cannot become a way around the policy. The local vine serve has no answer tool at all, because it has no bindings.
Datasets
A spreadsheet or a CSV that entered the forest is a real SQLite table, not chunked prose. The path an agent takes is look then query: the digest of a dataset node carries a query_manual with the tables and columns, sample rows, and the notes a person wrote about how to read the data.
query(forest, id, sql)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
id | string | required | none | A dataset node with a sqlite payload. |
sql | string | required | none | A single SELECT or WITH statement. LIMIT 200 is injected when absent, and execution is capped at 2 seconds. |
A mistyped table or column comes back as E_QUERY_INVALID with the real names in the hint, so a generated query can correct itself without spending another call. When the grant pins the tables for a dataset, naming any other table is refused.
view
A media node's body is a machine-written description of an image. view hands your model the pixels themselves. It returns a JSON header beside an MCP image content block, so a multimodal client reads the image into its own context:
[
{
"id": "media/onboarding-screenshot",
"media_type": "image/png",
"size": 184223,
"payload_hash": "7c1e0b9a4d2f8365b1e0c9a4d2f8365b1e0c9a4d"
},
<image content block>
]Images only, local payloads only, bounded at 6 MiB. A node with no payload answers the same E_NOT_FOUND envelope a missing node does. A remote payload is refused rather than fetched inside a read, and anything that is not an image stays with the surface that already serves it: query for datasets, and the REST payload route for raw bytes of any kind.
Writing back
plant and graft are atomic git commits inside the forest, stamped with the acting principal. They need the write capability, which a paired key does not carry by default, so an agent that should write needs a key an administrator minted.
plant(forest, node) and graft(forest, id, patch)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
node | object | optional | none | plant only. At least id, type, parent, title and summary. Pass schema to birth a dataset with its SQLite payload and query manual. |
id | string | optional | none | graft only. The node to edit. |
patch | object | optional | none | graft only: set_frontmatter, add_links, remove_links, append_section, replace_section, replace_body. |
tend is the dataset write: one INSERT, UPDATE or DELETE per call, WHERE mandatory on the last two, and no DDL ever. The binary never enters git; what is committed is the node's markdown carrying the new payload hash.
There is no route by which an agent deletes a node
Nodes are created and edited, never removed over any surface. What an agent writes is a commit in the forest's history, and a mistake is corrected the way any mistake in git is corrected.
ingest
The write a paired key actually carries. It puts documents through the Gardener, the same converters, curation and commits an operator gets, so an agent ingests exactly as a person does.
ingest(forest, mode, files, path, dest, wait)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Forest id. |
mode | string | optional | "upload" | upload sends the documents themselves. adopt and sync mirror a directory the Station host can read and additionally need admin. |
files | object[] | optional | none | For upload: entries of {"name": "notes.md", "text": "..."} or {"name": "report.docx", "b64": "..."}. |
path | string | optional | none | The host directory, for adopt and sync. |
dest | string | optional | none | The existing branch everything lands under. Required for a scoped key. |
wait | boolean | optional | true | Waits for the batch and returns the finished job. Pass false to get the running job id back immediately. |
A batch runs as a job. Unlike the REST route, this tool waits by default, because an agent poll loop would be context spent on plumbing. One batch per forest at a time: a second one while the first runs answers E_LOCKED naming the job.
How a key narrows the forest
Scoping only ever narrows content. It never changes the shape of a response, and it never produces a distinguishable refusal. That is the property that lets one agent codebase work against a full forest and a tightly scoped one without branching.
Four consequences an agent author should design around:
- No existence oracle. A node outside the scope answers
E_NOT_FOUNDwithnode not found: {id}, byte for byte identical to a node that never existed. The same applies to a forest: no grant and no such forest both answerunknown forest. - No truncation oracle. Derived counts are recomputed from what survived.
sniffreportsscanned_nodesas the nodes the key can see, not the bodies the engine opened; a branch'scoverageand a node'sstats.degreecount only visible children and edges. - Trails and edges are filtered too. The
trailof ancestor ids on a result is scrubbed of nodes the key may not see, and a graph edge needs both ends visible, because one visible end discloses the other. - Writes refuse differently, on purpose. A
plantoutside the grant answersE_FORBIDDEN, not not-found: the caller supplied the id, so saying so discloses nothing.
A full session
Here is what the loop actually looks like from the agent's side. A paired key with {read, ingest}, scoped to policies/ in a forest called handbook. The user asked: do I need a receipt for a 30 euro lunch?
- 1
Find out what this key can reach
The agent has no master index, so it starts by asking. The reply names the forest and the root to begin at.
pythoncallforests()jsonresult{ "forests": [ { "id": "handbook", "caps": ["ingest", "read"], "roots": ["policies/_index"] } ] } - 2
Drop near the answer
Rather than reading the branch, the agent searches curated metadata for entry points. Two candidates come back, ranked, each with the summary that will decide the next hop.
pythoncalllocate(forest="handbook", query="expense receipts over 25 euros", k=3)jsonresult{ "results": [ { "id": "policies/expenses", "kind": "banana", "type": "note", "title": "Expense policy", "summary": "What the company reimburses, the receipt rule and the limits per category.", "trail": ["policies/_index"], "score": 1.2841, "heat": 0.34 }, { "id": "policies/travel", "kind": "banana", "type": "note", "title": "Travel booking", "summary": "How to book flights and hotels, and which fares need approval.", "trail": ["policies/_index"], "score": 0.7712, "heat": 0.05 } ], "truncated": false }Note the
trail: it begins atpolicies/_index, not at_index. The master index is outside this key's scope, so it was scrubbed. Nothing in the response says so. - 3
Read the digest before paying for the body
The first result looks right, but its body is 812 tokens and the answer is probably in one section.
lookcosts at most 500 tokens and names the sections.pythoncalllook(forest="handbook", id="policies/expenses")jsonresult{ "id": "policies/expenses", "type": "note", "title": "Expense policy", "summary": "What the company reimburses, the receipt rule and the limits per category.", "tags": ["finance", "policy"], "confidence": 0.9, "updated": "2026-07-30", "edges_out": [ { "rel": "related-to", "target": "policies/travel", "target_summary": "How to book flights and hotels, and which fares need approval." } ], "edges_in": [], "outline": ["Limits", "Receipts", "Approvals"], "stats": { "body_tokens": 812, "degree": 1, "heat": 0.34 } }outlinesays there is a Receipts section. The agent now knows exactly what to ask for, and it has not spent the body yet. - 4
Take only the section that answers the question
pythoncallpick(forest="handbook", id="policies/expenses", section="Receipts")jsonresult{ "id": "policies/expenses", "title": "Expense policy", "section": "Receipts", "body": "## Receipts\n\nAny single item over 25 EUR needs a receipt attached to the claim. Below that, the line item alone is enough. Receipts are kept for seven years, so photograph them the day you spend.", "body_tokens": 148, "truncated": false }148 tokens instead of 812, and the fact is in them. Four calls, well under a thousand tokens of context, and the agent can cite the node it stands on.
- 5
Answer, with the citation
textwhat the user seesYes: anything over 25 EUR needs a receipt attached to the claim, and below that the line item alone is enough. Receipts are kept for seven years, so photograph them the same day. Source: policies/expenses (Receipts).The hunt also deposited pheromone on the trail it used, so
policies/expensesranks a little higher for the next question that goes this way. - 6
Keep what is worth keeping
The user then mentions the threshold changes in January. That is durable, so the agent offers to save it, using the write its key actually carries.
pythoncallingest( forest="handbook", mode="upload", dest="policies", files=[{ "name": "receipt-threshold-2027.md", "text": "# Receipt threshold 2027\n\nFrom January the threshold rises to 40 EUR." }] )jsonresult{ "job": { "id": "ing-4b7f21ac", "forest": "handbook", "mode": "upload", "state": "done", "done": 1, "total": 1, "current": null, "stage": null, "errors": 0, "started": "2026-08-15T09:41:12Z", "finished": "2026-08-15T09:41:19Z", "report": { "planted": ["policies/receipt-threshold-2027"], "branches": ["policies/_index"], "updated": [], "unchanged": [], "stale": [], "unsupported": [], "errors": [], "mode": "upload", "staged": ["receipt-threshold-2027.md"], "commit": "b2c9e7a1d0f83546c9b2e7a1d0f83546c9b2e7a1", "curated": true, "bound": true } } }It went through the Gardener like any other document: converted, curated into a passport, committed. The next session, and the next person, will find it through
locate.
When to skip the loop
The same question through answer(forest, question) is one call and returns the grounded text with its evidence, at the cost of a provider round trip on the Station. Through harvest(forest, query) it is one call returning the material for the agent to read itself. The four-hop loop above is what you want when the agent is following a thread that a single sweep would not resolve.
The Skills console
A connected agent knows the tools exist. It does not yet have the habit of using them, and a tool an agent never reaches for is indistinguishable from a tool that is not installed. The Skills console in the Studio closes that gap: it generates a small instruction file that teaches an agent runtime to treat the open forest as its memory.
The file is generated in your browser, for that exact deployment. Every snippet on the page already carries the Station's real address and the open forest's name, so there is nothing to substitute by hand and no way for the instructions to drift from the deployment they describe. The Station gains no endpoint for it. It is available to anyone whose key can read the forest, never admin-gated, because pairing made the credential self-service and learning to connect must be too.
For Claude Code the file installs at:
~/.claude/skills/monkeyllm-memory/SKILL.mdUnder the title This forest is your memory, it teaches three things:
- Recall before you answer. For any question the forest could answer, recall first and reason after:
answerwhen the forest's answer is the answer,harvestwhen the agent will reason over the material,locatethenlookthenpickto navigate,snifffor literal text inside bodies, andqueryfor datasets after alook, because the dataset's notes say what the columns mean. Cite node ids for anything asserted from the forest. - Save what is worth keeping. When the user states something durable, a decision, a fact, a preference, a correction, offer to keep it, using the write the key actually allows. It teaches
ingestfor a paired key andplant/graftonly for keys that carrywrite. - Respect the contract. Never work around a refusal: say what was refused and which capability it needs. Every read is budgeted, and
truncated: truemeans ask narrower, not retry harder.
The file's body is English regardless of the console language, on purpose: it addresses the model, not you. Hand the same file to any other runtime, adapted to however it loads system prompts or skills.
Errors and budgets
Tool failures come back as the same envelope every other surface uses, as the tool result rather than as a protocol error, so an agent can read the hint and correct itself.
{
"error": {
"code": "E_FORBIDDEN",
"message": "missing or invalid API key",
"hint": "Send Authorization: Bearer <key>."
}
}{
"error": {
"code": "E_FORBIDDEN",
"message": "'tend' requires the 'tend' capability",
"hint": "This principal holds: ['ingest', 'read']."
}
}{
"error": {
"code": "E_NOT_FOUND",
"message": "node not found: finance/salaries",
"hint": "Use locate() to find entry points."
}
}The third one is worth reading twice. It is not a permission error. It is the exact envelope a genuinely missing node produces, and that is deliberate: an error that said forbidden would itself disclose that something is there.
Every read answers within a declared token budget, and a cut result always says truncated: true. There is no silent cut anywhere.
| Tool | Budget |
|---|---|
look | 500 tokens |
move | 600 tokens |
locate, scan, sniff | 800 tokens each |
query | 2000 tokens |
pick, harvest | 4000 tokens each |
These budgets are why a forest stays navigable by a small local model, and why the skill file teaches ask narrower, not retry harder.