Base URL
A Station is one container serving three surfaces from one origin: the Studio console at /, this REST API under /v1, and the MCP endpoint at /mcp/. All three authenticate with the same keys and reach forests through the same policy gate.
https://station.example.com/v1Run locally, station serve binds 127.0.0.1:8800 by default, so the base URL is http://localhost:8800/v1. Both the bind address and the port are flags:
station serve --root /forests --registry /registry/station.db \
--host 0.0.0.0 --port 8800 --writableEvery request and every response is JSON, with one exception: POST /v1/admin/snapshots/import takes a multipart form because it carries a bundle file. Bodies are optional on primitive routes: an empty body is read as {}, and a body that is not a JSON object is refused with E_SCHEMA.
Reads work, writes are opt in
A Station started without --writable serves reads normally and refuses plant, graft, tend and ingest with E_READONLY. GET /v1/health reports which one you are talking to in its writable field.
Versioning and changes
This reference describes the Station shipped with MonkeyLLM v0.1.0, reviewed 2026-08-15. The engine is pre-1.0: it is in use, the shapes below are the shapes it returns today, and the surface is still moving. Pin the version you tested against.
/v1 is the compatibility unit, and what it promises is narrow and mechanical:
- Fields may be added. A new key in a response object, a new optional parameter on a request. Parse defensively: ignore keys you do not know rather than rejecting the payload.
- Fields are not removed or retyped under the same prefix. A route that has to drop a field or change its type gets a new prefix instead.
/v1does not change meaning underneath a client that already works. - Error codes are stable, messages are not. Branch on
error.code(see Errors). Never match onerror.messageorerror.hint: both are written for a human reading a log and are edited freely. - Budgets and caps are deployment settings, not contract. The default
k, the token budgets and the rate limits move with configuration. Read them from the response (truncated, theRateLimit-*headers) rather than assuming a number.
Breaking changes are announced in the engine repository's release notes and carried in the changelog. GET /v1/health is the runtime check: it reports the mode and whether the Station is writable, which is what a client should assert on startup rather than inferring from a version string.
Authentication
Every route except GET /v1/health and the three /v1/auth/* doors requires a key. A key identifies a principal; the principal holds grants; a grant binds one principal to one forest with capabilities and a branch scope. There is exactly one authorization path, and every door feeds into it.
Presenting a key
Send the key as a bearer token, or in X-Api-Key if that is easier for your client. Keys begin with mk_. Only a digest is stored, so the plaintext exists exactly once, in the reply to the call that minted it.
curl -s https://station.example.com/v1/me \
-H "Authorization: Bearer $MONKEYLLM_KEY"
# The same key, sent the other way:
curl -s https://station.example.com/v1/me \
-H "X-Api-Key: $MONKEYLLM_KEY"A missing, unknown, revoked or expired key is one answer with status 401:
{
"error": {
"code": "E_FORBIDDEN",
"message": "missing or invalid API key"
}
}Claiming a new Station
/v1/auth/setupCreate the owner account. Unauthenticated, and open only while the registry holds no credential at all.
This is the one unauthenticated write in the API, and it is safe for exactly one reason: it exists only while there is nothing to escalate from. The moment it succeeds, or the moment a deployment declares an environment super admin, the route answers exactly as an unrouted path does, with 404 and no such endpoint: /auth/setup. It never says already configured, because whether a Station is up for grabs is not public information.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
username | string | required | none | The owner principal id. |
password | string | required | none | At least 12 characters. This one credential governs every forest, present and future. |
email | string | optional | none | Optional, stored on the principal record. |
Request
curl -sX POST https://station.example.com/v1/auth/setup \
-H 'content-type: application/json' \
-d '{
"username": "jimmy",
"password": "a-long-enough-passphrase",
"email": "[email protected]"
}'Response
{
"key": "mk_9tK2wQ8pv1RfL0nYcXhA6ZbM4sEuD3gJ7iOaVrT5kNw",
"principal": "jimmy",
"expires_at": "2026-08-16T09:41:12+00:00",
"admin": true,
"owner": true
}Sessions from a password
/v1/auth/loginExchange a username and password for a session key that lives 12 hours.
The session token is an ordinary API key with a short life, so everything downstream, authentication, policy and audit, is the single path it already was. The door decides how the principal was established, never what it may do.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
username | string | required | none | Principal id. |
password | string | required | none | The stored password, or the environment super admin password. |
Request
curl -sX POST https://station.example.com/v1/auth/login \
-H 'content-type: application/json' \
-d '{"username": "jimmy", "password": "a-long-enough-passphrase"}'Response
{
"key": "mk_Xb7QpL2mR9wKvA0sZfEyH4nJdT6cU1gOiB3rY8kNVta",
"principal": "jimmy",
"expires_at": "2026-08-15T21:41:12+00:00",
"admin": true,
"owner": true
}A wrong password, an unknown user and a user with no password at all return the same 401 and the same message, invalid username or password. Distinguishing them would turn the login form into a directory of who exists.
Paired keys
/v1/auth/pairSelf-service credential for an agent or an extension. Unauthenticated like login, and what it mints is strictly narrower than what login opens.
Pairing is the door you hand a machine. It is unauthenticated for the same reason login is: it reaches nothing the password could not already reach, so an admin gate in front of it would only route the same authority through a wider credential. What makes the result safe is that a paired key 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 withE_SCHEMA. - Grants intersect the mask at the moment of use. 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. Absent or
0means the default, never unlimited, and a request over the ceiling is told the ceiling rather than silently clamped.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
username | string | required | none | Principal id. |
password | string | required | none | The same password login takes. |
caps | string[] | optional | ["read","ingest"] | Must be a subset of read and ingest. An empty list or null means the default. |
expires_in_days | number | optional | 90 | Positive and finite, at most 365. Absent or 0 means 90. |
label | string | optional | "clipper" | Shown in the Access console so a human can tell keys apart. |
Request
curl -sX POST https://station.example.com/v1/auth/pair \
-H 'content-type: application/json' \
-d '{
"username": "jimmy",
"password": "a-long-enough-passphrase",
"label": "claude-code",
"caps": ["read", "ingest"],
"expires_in_days": 90
}'Response
{
"api_key": "mk_D5vN8hZq2WcJ1yLpXeT7bR0mKsA4gFuI6oQnV3rYtBd",
"principal": "jimmy",
"caps": ["ingest", "read"],
"expires_at": "2026-11-13T09:41:12+00:00"
}Minting and revoking keys
/v1/admin/keysadminList key metadata for every principal you fully administer. Never the secret: only its digest, prefix and status.
/v1/admin/keysadminMint a key for a principal, or revoke one by its digest.
A key authenticates a principal, and a principal may hold grants on several forests, so issuing one requires admin on every forest that principal is granted, not merely on one of them. Otherwise the administrator of one forest could mint a credential that opens another. The owner satisfies this trivially.
POST body
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
principal | string | required | none | Who the key is for. Required unless you are revoking. |
label | string | optional | none | Free text shown in listings. |
expires_in_days | number | optional | none | Absent, empty or 0 mints a key with no expiry. Unlike a paired key, an administrator mint has no ceiling. |
revoke | string | optional | none | A key id (its digest, from the GET listing). When present the call revokes instead of minting and answers {revoked, principal}. |
Request
curl -sX POST https://station.example.com/v1/admin/keys \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{"principal": "reporting-bot", "label": "nightly", "expires_in_days": 30}'Response
{
"api_key": "mk_T4jRw9YbK1sQ6hLzXaP0cVnE8mdU2fGoI5rNtA7vZyC",
"principal": "reporting-bot",
"keys": [
{
"id": "6f1c0b8e4a2d97c53b0e1f8a6d4c2b90e7a1f3c5d8b6094e2a7c1f5b3d9e0a84",
"principal": "reporting-bot",
"label": "nightly",
"created": "2026-08-15T09:41:12+00:00",
"prefix": "mk_T4jRw",
"expires_at": "2026-09-14T09:41:12+00:00",
"revoked_at": null,
"last_used_at": null,
"status": "active"
}
]
}status is derived, one of active, expired or revoked. Session tokens minted by /v1/auth/login are deliberately excluded from this listing: they are the by-product of a login, not a credential anyone manages.
Capabilities and scope
There are exactly six capabilities.
| Capability | Unlocks |
|---|---|
read | locate, look, move, pick, scan, sniff, harvest, answer, the map projections and the payload route |
query | query: read-only SQL over a dataset node |
write | plant, graft, curate |
tend | tend: single-statement dataset writes |
ingest | ingest and the job routes that watch a batch |
admin | Everything above on that forest, plus the /v1/admin routes |
A grant also carries branch-prefix scope: allow and deny lists of subtree prefixes, where deny wins at any depth and an empty allow means the whole forest. Prefixes compare with a trailing slash, so a grant on projects/ cannot accidentally swallow projects-secret/. A grant may also pin tables, a per-dataset list of the SQL tables the principal may name.
Out of scope is indistinguishable from absent
A node your key may not see answers E_NOT_FOUND with the message node not found: {id}, byte for byte the same as a node that never existed. An error that said forbidden would itself disclose the node. The same rule applies to forests: no grant and no such forest both answer unknown forest: {id}.
Filtering also happens before ranking and budgeting, and every derived count (coverage, stats.degree, scanned_nodes) is recomputed from what survived, so a result count or a truncation flag cannot be read as a measurement of what was hidden.
Errors
Every failure is one envelope. hint is present when there is something actionable to say, and it is written for the caller, so show it.
{
"error": {
"code": "E_FORBIDDEN",
"message": "'tend' requires the 'tend' capability",
"hint": "This principal holds: ['ingest', 'read']."
}
}The code maps onto the HTTP status:
| Code | Status | Means |
|---|---|---|
E_NOT_FOUND | 404 | No such node, forest, section, job or endpoint. Also: out of scope. |
E_SCHEMA | 400 | The request was malformed, or an argument was wrong. |
E_FRONTMATTER | 400 | A node passport failed validation on a write. |
E_FORBIDDEN | 403 | The principal is missing a capability, or is writing outside its grant. Returned with 401 when the key itself is missing or invalid. |
E_READONLY | 403 | This Station serves read-only forests. |
E_QUERY_FORBIDDEN | 403 | The SQL guard refused: not a dataset, more than one statement, a forbidden keyword, or a table the grant does not permit. |
E_QUERY_INVALID | 400 | SQLite refused the statement itself, a mistyped table or column. Retrying with a corrected statement is the right move. |
E_LOCKED | 409 | A batch is already running on this forest, or a writer lock is held. |
E_TIMEOUT | 504 | A query exceeded its 2 second deadline. |
A code with no mapping falls back to 400. Beyond the table, three statuses come from the transport rather than from a code: 202 when a batch was accepted (see Ingestion), 304 when a payload or the Clipper archive revalidated against its ETag, and 429 from the password doors (see rate limits).
Any path under /v1 that matches no route answers as the API rather than falling through to the console: E_NOT_FOUND with no such endpoint: {path} and the hint Check the method too: several /v1/admin routes are POST-only.
Discovery
Three routes answer what is this Station and what may I do here. Start with them: a scoped key has no master index, so roots is how a client learns where it may begin.
Health
/v1/healthLiveness and the two facts a sign-in screen needs. The only route that takes no key.
Request
curl -s https://station.example.com/v1/healthResponse
{
"status": "ok",
"mode": "registry",
"writable": true,
"setup_required": false,
"password_login": true
}mode is registry when the Station hosts many forests under a root, single when it serves one. setup_required and password_login say which pre-identity screen a console should render. They reveal that a door exists, never who may walk through it.
Who am I
/v1/meThe calling principal, its grants as policy resolves them, and where each grant starts.
Request
curl -s https://station.example.com/v1/me \
-H "Authorization: Bearer $MONKEYLLM_KEY"Response
{
"principal": "reporting-bot",
"grants": [
{
"forest": "handbook",
"caps": ["ingest", "read"],
"allow": ["policies/"],
"deny": ["policies/legal-hold/"],
"tables": {},
"roots": ["policies/_index"]
}
],
"admin": false,
"owner": false
}For the owner there is no grant table to read, the authority is a bit, so every forest the pool currently holds is projected as a full-capability grant. When the key carries a capability mask, the caps reported here are the masked ones: what a console renders from this is what the key can actually do.
List forests
/v1/forestsEvery forest this key may use, with its capabilities and starting roots.
Request
curl -s https://station.example.com/v1/forests \
-H "Authorization: Bearer $MONKEYLLM_KEY"Response
{
"forests": [
{
"id": "handbook",
"active": true,
"caps": ["ingest", "read"],
"roots": ["policies/_index"]
}
],
"mode": "registry"
}active says the forest is already open in the pool. roots is ["_index"] for an unrestricted grant, and one {prefix}/_index per allowed subtree otherwise: there is no implicit grant on the ancestors of a granted subtree, because the master index names every branch in the forest.
Forest primitives
One route shape covers every call. POST the arguments as a JSON object to the primitive name, under the forest:
POST /v1/forests/{forest}/{primitive}The names this route serves:
| Group | Names | Needs |
|---|---|---|
| Read | locate, look, move, pick, scan, sniff, harvest | read |
| Dataset read | query | query |
| Write | plant, graft | write |
| Dataset write | tend | tend |
| Composite | answer | read plus a model bound to the answer role |
| Composite | curate | write plus a model bound to the ingest role |
| Host action | ingest | ingest |
Any other name answers 404 with no such endpoint: {name} and a hint listing the served set. Two extras apply to every call on this route:
hybrid(boolean, defaultfalse) turns on hybrid entry search for this call, fusing the vector index with BM25. It is consumed by the host, never passed to the primitive, and it is reset on every call rather than left over from the last one.- A
Server-Timingresponse header carries the host clocks, in milliseconds. It is emitted for refusals too, because how long a 403 took is not a fact about the forest behind it.httpServer-Timing: vine;dur=62.4, model;dur=1840.2, cache;dur=1.8, host;dur=9.1vineis the engine,modelthe provider round trip when there was one,cachethe answer store when it was consulted, andhostwhatever is left: policy, the audit record, serialisation and the thread hop.
No view over REST
The engine has a view primitive that hands a multimodal model the pixels behind a media node, but it is not served on this route: it returns an MCP image content block, which has no meaning over HTTP. Over REST, the bytes come from the payload route; over MCP, from the view tool.
locate
/v1/forests/{forest}/locatereadRanked entry points over curated metadata. Where to drop into the forest.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | required | none | Free text. Matched against titles, summaries, tags and aliases, not bodies. |
k | integer | optional | 5 | How many results to return. |
scope | string | optional | "all" | One of all, branches, bananas. Branches are index nodes, bananas are leaf nodes. |
type_filter | string | optional | none | Keep only one node type, e.g. dataset or media. The forest declares its own types. |
curl -sX POST https://station.example.com/v1/forests/handbook/locate \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{"query": "expense reimbursement", "k": 3, "scope": "bananas"}'import httpx
STATION = "https://station.example.com"
KEY = "mk_..."
def call(forest: str, primitive: str, **body):
r = httpx.post(
f"{STATION}/v1/forests/{forest}/{primitive}",
headers={"Authorization": f"Bearer {KEY}"},
json=body,
timeout=180.0,
)
payload = r.json()
if "error" in payload:
err = payload["error"]
raise RuntimeError(f"{err['code']}: {err['message']}")
return payload
hits = call("handbook", "locate", query="expense reimbursement", k=3,
scope="bananas")
for hit in hits["results"]:
print(hit["score"], hit["id"], "-", hit["summary"])const STATION = 'https://station.example.com';
const KEY = process.env.MONKEYLLM_KEY!;
type VineError = { error: { code: string; message: string; hint?: string } };
async function call<T>(
forest: string,
primitive: string,
body: Record<string, unknown> = {},
): Promise<T> {
const res = await fetch(`${STATION}/v1/forests/${forest}/${primitive}`, {
method: 'POST',
headers: {
authorization: `Bearer ${KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
const payload = (await res.json()) as T | VineError;
if (payload && typeof payload === 'object' && 'error' in payload) {
const { code, message } = (payload as VineError).error;
throw new Error(`${code}: ${message}`);
}
return payload as T;
}
const hits = await call<{
results: { id: string; score: number; summary: string }[];
truncated: boolean;
}>('handbook', 'locate', {
query: 'expense reimbursement',
k: 3,
scope: 'bananas',
});{
"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": ["_index", "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": ["_index", "policies/_index"],
"score": 0.7712,
"heat": 0.05
}
],
"truncated": false
}trail is the ancestor chain, filtered to what the key may see. heat is accumulated pheromone: successful hunts reinforce the nodes they used, so a corpus that is used becomes cheaper to navigate. Branch results also carry coverage, a one-line count of what is under them.
look
/v1/forests/{forest}/lookreadA cheap digest of one node: summary, edges, children or outline, and stats. Read this before paying for a body.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | The node id. |
fields | string[] | optional | none | Return only these keys (id is always included). Cheaper on dataset nodes, where query_manual, sample_rows and notes each open the payload. |
gauntlet | boolean | optional | none | Rank the frontier toward the current goal before it is cut. Follows the forest setting when omitted. |
toward | string | optional | none | Rank the frontier toward this text instead of the goal remembered from the last locate. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/look \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{"id": "policies/expenses"}'Response
{
"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": [
{ "rel": "mentions", "source": "policies/onboarding" }
],
"outline": ["Limits", "Receipts", "Approvals"],
"stats": { "body_tokens": 812, "degree": 2, "heat": 0.34 }
}The digest changes shape with the node:
- A branch carries
children,coverageand, when the index declares them,cross_trails. - A banana carries
outline, the list of its section headers. - A dataset additionally carries
query_manual(tables, columns and example queries),sample_rowsand the operator notes innotes. This is the path an agent takes before running SQL.
edges_out and edges_in are capped at 12 each and ordered by the heat of the node on the other end. If the digest still exceeds its 500-token budget, sample_rows is dropped and truncated: true appears.
move
/v1/forests/{forest}/movereadThe neighbours of a node along typed edges, or the physical children of a branch.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | The node to move from. |
rel | string | optional | none | Keep only this relation. The special value "children" lists a branch's physical children instead of its edges. |
direction | string | optional | "out" | out, in or both. Inbound edges are shown under their inverse relation, as the forest's dialect defines it. |
gauntlet | boolean | optional | none | As on look. |
toward | string | optional | none | As on look. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/move \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{"id": "policies/_index", "rel": "children"}'Response
{
"neighbors": [
{
"id": "policies/expenses",
"rel": "children",
"direction": "out",
"type": "note",
"summary": "What the company reimburses, the receipt rule and the limits per category.",
"heat": 0.34
},
{
"id": "policies/travel",
"rel": "children",
"direction": "out",
"type": "note",
"summary": "How to book flights and hotels, and which fares need approval.",
"heat": 0.05
}
],
"truncated": false
}pick
/v1/forests/{forest}/pickreadThe body of a node, or one section of it. The expensive call, taken only once the digest says this is the one.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | The node id. |
section | string | optional | none | A header from the node's outline. An unknown header answers E_NOT_FOUND with the available sections in the hint. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/pick \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{"id": "policies/expenses", "section": "Receipts"}'Response
{
"id": "policies/expenses",
"title": "Expense policy",
"section": "Receipts",
"body": "## Receipts\n\nAny single item over 25 EUR needs a receipt...",
"body_tokens": 148,
"truncated": false
}A body over 4000 tokens is never silently cut. Instead the call answers the outline plus a hint, so the caller can ask for one section:
{
"id": "policies/handbook-full",
"title": "The whole handbook",
"outline": ["Limits", "Receipts", "Approvals", "Appeals"],
"body_tokens": 9120,
"truncated": true,
"hint": "Body exceeds 4000 tokens. Use section=<header> to harvest one section."
}scan
/v1/forests/{forest}/scanreadA metadata query over a branch, answered from the catalog with no file opens.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
parent_id | string | required | none | The branch to scan, e.g. policies/_index. |
filter | object | optional | none | Any passport column, plus tags_any, updated_after, updated_before, created_after and min_confidence. An unknown key answers E_SCHEMA. |
fields | string[] | optional | ["id","type","summary"] | Which columns each row carries back. |
recursive | boolean | optional | false | Walk the whole subtree instead of the direct children. |
limit | integer | optional | 50 | Clamped to the range 1 to 50. |
gauntlet | boolean | optional | none | As on look. |
toward | string | optional | none | As on look. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/scan \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"parent_id": "policies/_index",
"filter": {"type": "note", "updated_after": "2026-01-01"},
"fields": ["id", "title", "updated"],
"recursive": true,
"limit": 25
}'Response
{
"nodes": [
{ "id": "policies/expenses", "title": "Expense policy", "updated": "2026-07-30" },
{ "id": "policies/travel", "title": "Travel booking", "updated": "2026-03-02" }
],
"truncated": false
}sniff
/v1/forests/{forest}/sniffreadLiteral search inside bodies: the exact codes, names and numbers that summaries do not carry.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
terms | string | string[] | required | none | One to 8 literal terms, each at least 2 characters after normalisation. Regular expressions are not supported. |
scope | string | optional | none | A branch id narrows to that subtree; a leaf node id greps inside that node alone. Omit it to sweep the whole visible forest. |
k | integer | optional | 5 | Clamped to a maximum of 20. |
type_filter | string | optional | none | Restrict to one node type. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/sniff \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{"terms": ["INV-1045", "reimbursement"], "scope": "policies", "k": 5}'Response
{
"results": [
{
"id": "policies/expenses",
"type": "note",
"title": "Expense policy",
"trail": ["_index", "policies/_index"],
"score": 1.34,
"heat": 0.34,
"match_count": 2,
"truncated_matches": false,
"matches": [
{
"section": "Receipts",
"line": 42,
"snippet": "...the disputed invoice INV-1045 was reimbursed in full after..."
}
]
}
],
"scanned_nodes": 37,
"truncated": false
}Each result carries at most 3 matches, with truncated_matches saying when there were more. scanned_nodes counts the bodies that were opened, and for a scoped key it is recomputed from what that key can see, so it is never a measurement of the forest's real size.
harvest
/v1/forests/{forest}/harvestreadOne-shot retrieval with no model on the server side: a locate and sniff sweep, rank-fused, returning the material itself.
This is the call for a client that brings its own model. It runs locate and sniff, fuses the two rankings, and returns each winner with the full body when it fits or the matched sections when it does not, always with exact snippets and the trail. Your model decides what to do next.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | required | none | The free-text question. |
terms | string[] | optional | none | Literal terms for the sniff half. Derived from the query when omitted: words of 4 characters or more, minus stopwords, capped at 8. |
k | integer | optional | 3 | How many nodes to bring back. Clamped to at least 1 and at most the deployment cap, 5 by default and set with MONKEYLLM_HARVEST_MAX_K. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/harvest \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{"query": "what is our expense policy?", "terms": ["receipt"], "k": 3}'Response
{
"query": "what is our expense policy?",
"terms": ["receipt"],
"results": [
{
"id": "policies/expenses",
"title": "Expense policy",
"type": "note",
"trail": ["_index", "policies/_index"],
"summary": "What the company reimburses, the receipt rule and the limits per category.",
"score": 0.0328,
"found_by": ["locate", "sniff"],
"matches": [
{
"section": "Receipts",
"line": 40,
"snippet": "...any single item over 25 EUR needs a receipt attached..."
}
],
"content": [
{
"section": null,
"body": "# Expense policy\n\n## Limits\n...",
"body_tokens": 812
}
]
}
],
"truncated": false,
"trace": {
"steps": [
{ "step": "locate", "ms": 11.4, "tokens": 320 },
{ "step": "sniff", "ms": 47.9, "tokens": 260 },
{ "step": "pick", "ms": 3.1, "tokens": 812, "id": "policies/expenses" }
],
"retrieval_ms": 62.4,
"total_ms": 62.4
}
}found_by says which half of the sweep found the node, and content is either one entry with the whole body, up to two matched sections, or the outline when the body is large and no section could be attributed. Dataset results additionally carry notes, because what a person wrote about how to read the data should not depend on whether the question happened to match it. Results are dropped whole from the tail to fit the 4000-token budget, never sliced mid-body.
answer
/v1/forests/{forest}/answerreadScoped retrieval read by the model bound to this forest, returning a grounded answer with its evidence.
The one call that replaces a knowledge-base lookup plus a summarisation round trip. The model only ever sees material the principal could already read, so binding a model cannot become a way around the policy. It needs a model bound to the answer role for this forest; without one it answers E_SCHEMA and tells you where to bind it.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
question | string | required | none | The question. query is accepted as an alias. |
k | integer | optional | 3 | How many nodes the retrieval half brings back. |
cache | boolean | optional | true | Pass false to skip the forest's answer store and buy a fresh run, which then replaces the stored one. |
reply_tokens | integer | optional | none | Bound this reply, clamped to the range 64 to 4000. Absent or 0 lets the forest binding decide. |
hops | boolean | integer | optional | none | Let the model navigate instead of reading one sweep. true means a budget of 6 hops, a number sets it. Each hop costs a model call, which is why the sweep stays the default. |
hybrid | boolean | optional | false | Fuse the vector index into the entry search for this call. |
curl -sX POST https://station.example.com/v1/forests/handbook/answer \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{"question": "what is our expense policy?", "k": 3}'import httpx
STATION = "https://station.example.com"
KEY = "mk_..."
r = httpx.post(
f"{STATION}/v1/forests/handbook/answer",
headers={"Authorization": f"Bearer {KEY}"},
json={"question": "what is our expense policy?", "k": 3},
timeout=180.0, # a provider round trip lives inside this call
)
payload = r.json()
if "error" in payload:
raise RuntimeError(payload["error"]["message"])
print(payload["answer"])
print("grounded in:", payload["evidence"])
print("server timing:", r.headers.get("server-timing"))const res = await fetch(
'https://station.example.com/v1/forests/handbook/answer',
{
method: 'POST',
headers: {
authorization: `Bearer ${process.env.MONKEYLLM_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ question: 'what is our expense policy?', k: 3 }),
},
);
type Answer = {
answer: string;
model: string;
evidence: string[];
sources: { id: string; title: string; summary: string; type: string }[];
cached?: boolean;
};
const payload = (await res.json()) as Answer;
console.log(payload.answer, payload.evidence);
console.log(res.headers.get('server-timing'));{
"answer": "Anything over 25 EUR needs a receipt, and travel over 500 EUR needs your manager's approval before booking.",
"model": "qwen3:14b",
"model_ms": 1840.2,
"usage": { "prompt": 2914, "completion": 84, "calls": 1 },
"evidence": ["policies/expenses", "policies/travel"],
"sources": [
{
"id": "policies/expenses",
"title": "Expense policy",
"summary": "What the company reimburses, the receipt rule and the limits per category.",
"type": "note"
}
],
"harvest": { "query": "what is our expense policy?", "results": [] },
"trace": {
"steps": [
{ "step": "locate", "ms": 11.4, "tokens": 320 },
{ "step": "sniff", "ms": 47.9, "tokens": 260 },
{ "step": "model", "ms": 1840.2, "detail": "qwen3:14b" }
],
"retrieval_ms": 62.4,
"total_ms": 1902.6
},
"cost": {
"prompt_tokens": 2914,
"completion_tokens": 84,
"calls": 1,
"priced": false
}
}evidence is the list of node ids the answer stands on, in rank order, and sources is the same list with enough metadata to render a citation. harvest is the whole retrieval bundle the model read, so you can show your user exactly what grounded the answer. cost appears when a provider actually ran; priced: false means the provider publishes usage but no rates, which is reported as not priced rather than as free.
A repeat of a question may be served from the forest's answer store. A hit is labelled and carries no model_ms and no trace, because no provider ran:
{
"answer": "Anything over 25 EUR needs a receipt, and travel over 500 EUR needs your manager's approval before booking.",
"model": "qwen3:14b",
"usage": { "prompt": 2914, "completion": 84, "calls": 1 },
"cached": true,
"cached_at": "2026-08-15T08:12:44+00:00",
"evidence": ["policies/expenses", "policies/travel"],
"sources": [],
"harvest": { "query": "what is our expense policy?", "results": [] }
}query
/v1/forests/{forest}/queryqueryRead-only SQL against a dataset node's SQLite payload.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | A node of type dataset with a sqlite payload. Anything else answers E_QUERY_FORBIDDEN. |
sql | string | required | none | A single statement starting with SELECT or WITH. Semicolons beyond a trailing one are refused, as are write and DDL keywords. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/query \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"id": "finance/ledger-2026",
"sql": "SELECT month, SUM(amount) AS total FROM entries GROUP BY month"
}'Response
{
"columns": ["month", "total"],
"rows": [
["2026-01", 481200.55],
["2026-02", 512844.10]
],
"row_count": 2,
"limited": false,
"elapsed_ms": 3.71
}A statement with no LIMIT gets LIMIT 200 appended, and limited: true says the cap was reached. Execution is capped at 2 seconds, past which the call answers E_TIMEOUT. A mistyped table or column answers E_QUERY_INVALID with the real names in the hint, so a generated query can correct itself without spending a look. When the grant pins tables for this dataset, naming any other table answers E_FORBIDDEN.
plant
/v1/forests/{forest}/plantwriteCreate a node: file, index entry and git commit, atomically.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
node | object | required | none | The node specification. id, type, parent, title and summary are all required, and a body missing any one of them answers E_SCHEMA. tags, body, links, confidence, source and the payload and schema fields are optional. For a dataset, pass schema and the engine births the SQLite payload and its query manual; rows then enter through tend. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/plant \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"node": {
"id": "policies/per-diem",
"parent": "policies/_index",
"type": "note",
"title": "Per diem rates",
"summary": "Daily allowance per country and what it is meant to cover.",
"tags": ["finance"],
"body": "# Per diem rates\n\n## Europe\n55 EUR per day."
}
}'Response
{
"id": "policies/per-diem",
"commit": "4f1b0c9a8d2e6b3705c1f4a9e8d7b6c5a4930f21",
"trail": ["_index", "policies/_index"]
}A write outside the grant is refused as E_FORBIDDEN, not as not-found: the caller supplied the id, so nothing is disclosed by saying so. The commit is stamped with the acting principal, and commit in the response is that stamped sha.
graft
/v1/forests/{forest}/graftwriteEdit a node in one atomic patch. Summary changes propagate verbatim to every index that replicates them.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | The node to edit. |
patch | object | required | none | Any combination of set_frontmatter, add_links, remove_links, append_section, replace_section and replace_body. An empty patch answers E_SCHEMA. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/graft \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"id": "policies/per-diem",
"patch": {
"set_frontmatter": {"summary": "Daily allowance per country, updated for 2027."},
"add_links": [{"rel": "related-to", "target": "policies/expenses"}],
"append_section": {"header": "Asia", "body": "70 EUR per day."}
}
}'Response
{
"id": "policies/per-diem",
"commit": "9c2d7e5b1a0f8364d5e2c7b9a1e0f8364d5e2c7b",
"fortified": [{ "rel": "related-to", "target": "policies/expenses" }],
"trail": ["_index", "policies/_index"]
}Two rules the patch validator enforces: replace_body cannot be combined with section operations (send the whole body, or section patches, not both), and an index node's body is the indexer's render, so an index accepts section operations only. fortified lists the links that already existed and were reinforced rather than duplicated.
tend
/v1/forests/{forest}/tendtendA single-statement write into a dataset payload, committed with an audit reference.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | The dataset node. |
sql | string | required | none | One INSERT, UPDATE or DELETE. WHERE is mandatory on UPDATE and DELETE. DDL is never allowed. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/tend \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"id": "finance/ledger-2026",
"sql": "UPDATE entries SET amount = 1290.00 WHERE id = 4471"
}'Response
{
"id": "finance/ledger-2026",
"rows_affected": 1,
"payload_hash": "b71d0f4c8a2e59637d1c0b8f4a2e5963d7c1b0f8a4e2596371d0c8b4f2a0e5963",
"commit": "7a3e1c0b9d8f2465e3c1a0b9d8f24657e3c1a0b9",
"elapsed_ms": 2.14
}The binary never enters git. What is committed is the node's .md carrying the new payload_hash, which is the audit reference for the change.
curate
/v1/forests/{forest}/curatewriteRe-summarise one node with the model bound to the ingest role. Proposes: it does not write.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | The node to re-curate. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/curate \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{"id": "policies/per-diem"}'Response
{
"id": "policies/per-diem",
"model": "qwen3:14b",
"before": "Daily allowance per country, updated for 2027.",
"after": "Per diem rates by region: 55 EUR in Europe, 70 EUR in Asia, covering meals and local transport.",
"tags": ["finance", "travel"]
}The reply is a proposal for a human or a caller to apply with graft. The curator validates the model against the summary contract and retries, so a model that writes a 200-token summary is corrected rather than trusted.
Ingestion
/v1/forests/{forest}/ingestingestPut documents into the forest through the Gardener: converters, curation and commits, exactly as an operator gets them.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
mode | string | optional | "adopt" | upload sends the documents themselves; adopt mirrors a directory the Station host can read; sync hash-diffs a previously adopted source and refreshes what changed; compose takes authored prose and answers in place. |
files | object[] | optional | none | Required for upload. Each entry is {"name": "notes.md", "text": "..."} or {"name": "report.docx", "b64": "..."}. An entry may also carry a source_url (http or https, at most 2048 characters) recording where its bytes came from. |
dest | string | optional | none | The branch path segment everything lands under. Optional for an unrestricted principal; required for a scoped one, and it must be a branch that already exists. |
path | string | optional | none | For adopt, a directory on the Station host. For sync, a path relative to the recorded source root, to reconcile just that file. Naming a host path additionally requires admin. |
source | string | optional | none | The source directory. Same authority rules as path. |
wait | boolean | optional | false | Hold the response open until the batch finishes and return the completed job. Otherwise the call answers 202 immediately. |
title | string | optional | none | compose only. Names the node and its file. |
text | string | optional | none | compose only. The markdown body. |
stage | boolean | optional | false | compose only. Preview the draft the Gardener would plant, without planting it. |
draft | object | optional | none | compose only. A reviewed draft from a previous stage call, accepted as written. A request may stage or accept, never both. |
Request
curl -sX POST https://station.example.com/v1/forests/handbook/ingest \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"mode": "upload",
"dest": "policies",
"files": [
{"name": "per-diem.md", "text": "# Per diem\n\n55 EUR per day in Europe."},
{"name": "handbook.docx", "b64": "UEsDBBQABgAIAAAAIQ..."}
]
}'Response
HTTP/1.1 202 Accepted
{
"job": {
"id": "ing-4b7f21ac",
"forest": "handbook",
"mode": "upload",
"state": "running",
"done": 0,
"total": 2,
"current": null,
"stage": null,
"errors": 0,
"started": "2026-08-15T09:41:12Z"
}
}Batches run as jobs. One batch per forest at a time: a second one while the first is running answers 409, naming the job you can watch or cancel.
HTTP/1.1 409 Conflict
{
"error": {
"code": "E_LOCKED",
"message": "an ingest job is already running on this forest: ing-4b7f21ac",
"hint": "Watch it under GET /v1/forests/{forest}/jobs, cancel it, or wait for it to finish."
}
}Host paths spend the Station's authority, not yours
adopt and sync read the Station's own filesystem, so naming a path requires admin on top of ingest, and the path must additionally sit inside the directories listed in MONKEYLLM_INGEST_ROOTS. Without both, a content capability would be able to read anything the container can see. Use mode: "upload" to send the documents themselves and neither applies.
/v1/forests/{forest}/ingestingestWhat a refresh would re-read, before anyone asks for one.
{
"source": "/srv/documents/handbook",
"can_sync": true,
"host_paths": true
}source is the root a prior adopt recorded, can_sync says that root is still inside the ingest roots this Station may read, and host_paths says whether mirroring a host folder is offered at all. A console that offers a sync button without reading this is offering a button whose scope is invisible.
Ingest jobs
A job is process state, never forest content, so reading one touches no forest: no lane, no trace event, no pheromone. That is what keeps polling free while a batch runs. All three routes require the ingest capability, on the reasoning that whoever could have started the batch may watch it.
/v1/forests/{forest}/jobsingestThe jobs this Station remembers for the forest, newest first.
/v1/forests/{forest}/jobs/{job}ingestOne job, with its report once it has finished.
/v1/forests/{forest}/jobs/{job}/cancelingestAsk the driver to stop at the next document boundary. A no-op on a finished job, never an error.
{
"job": {
"id": "ing-4b7f21ac",
"forest": "handbook",
"mode": "upload",
"state": "done",
"done": 2,
"total": 2,
"current": null,
"stage": null,
"errors": 0,
"started": "2026-08-15T09:41:12Z",
"finished": "2026-08-15T09:41:29Z",
"report": {
"planted": ["policies/per-diem", "policies/handbook"],
"branches": ["policies/_index"],
"updated": [],
"unchanged": [],
"stale": [],
"unsupported": [],
"errors": [],
"mode": "upload",
"staged": ["per-diem.md", "handbook.docx"],
"rollup": null,
"commit": "b2c9e7a1d0f83546c9b2e7a1d0f83546c9b2e7a1",
"commit_before": "a1b8d6c0f9e72435b8a1d6c0f9e72435b8a1d6c0",
"curated": true,
"bound": true,
"curation": { "llm_summaries": 2, "branch_rollups": 1 }
}
}
}| Field | Meaning |
|---|---|
state | running, then one of done, error or cancelled |
done / total | Documents finished, and how many the walk found |
current / stage | The document being processed and the phase it is in, so a batch of one large file shows movement instead of standing still |
errors | How many documents failed. A batch does not stop on one bad file |
report | Present once finished: planted, branches, updated, unchanged, stale, unsupported and errors, plus the commit range and the curation statistics |
A restart forgets job records, never the work
The board keeps 20 finished records per forest and reports truncated: true when the bound cut. Records live in memory. A restart loses them, and it cannot lose the work, because the work is git commits: the forest's own account is the audit log and the git log.
Map projections
Two GET routes hand back a whole region in one payload, under the caller's own policy. They are not primitives and grant nothing new: every id they return is one the same principal could reach through look. What they add is shape, which a per-node walk cannot give without asking once per node.
/v1/forests/{forest}/graphreadNodes and typed edges as a graph, with degree and heat per node.
/v1/forests/{forest}/trailsreadAccumulated pheromone per node, ranked, with summary statistics.
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
scope | string | optional | none | A branch to restrict to. Both projects/_index and the bare projects name the same region; empty or _index means the whole visible forest. |
limit | integer | optional | 2000 | Clamped to the range 1 to 10000. |
Request
curl -s "https://station.example.com/v1/forests/handbook/graph?scope=policies&limit=500" \
-H "Authorization: Bearer $MONKEYLLM_KEY"Response
{
"nodes": [
{
"id": "policies/expenses",
"kind": "banana",
"type": "note",
"title": "Expense policy",
"summary": "What the company reimburses, the receipt rule and the limits per category.",
"tags": ["finance", "policy"],
"parent": "policies/_index",
"coverage": null,
"body_tokens": 812,
"payload": null,
"payload_type": null,
"created": "2026-02-11",
"updated": "2026-07-30",
"degree": 2,
"heat": 0.34
}
],
"edges": [
{
"src": "policies/expenses",
"rel": "related-to",
"dst": "policies/travel",
"confidence": 1.0
}
],
"truncated": false,
"types": ["dataset", "entity", "media", "note"],
"rels": ["part-of", "related-to", "succeeds"],
"derived": true
}An edge needs both ends visible, because one visible end discloses the other, and degree is recomputed from the edges that survived rather than read off the catalog. When the node count exceeds the limit, the most connected nodes are kept, ranked over the in-scope edge set only, and truncated: true says so. types and rels are the dialect this forest declares, so a legend names what the forest actually holds.
{
"heat": [
{ "id": "policies/expenses", "heat": 0.34 },
{ "id": "policies/travel", "heat": 0.05 }
],
"stats": { "rows": 2, "max": 0.34, "mean": 0.195 },
"truncated": false,
"derived": true
}Payload bytes
/v1/forests/{forest}/payload/{node}readThe raw bytes behind a node's textual proxy: the screenshot, the .db, the original file.
This is a human surface: the console shows the screenshot, the browser saves the database. The node id goes into the path as-is, slashes included. It is served by a read-only Station too, because it writes nothing.
Request
curl -s -o screenshot.png -D - \
"https://station.example.com/v1/forests/handbook/payload/media/onboarding-screenshot" \
-H "Authorization: Bearer $MONKEYLLM_KEY"Response
HTTP/1.1 200 OK
content-type: image/png
content-length: 184223
cache-control: private
etag: 7c1e0b9a4d2f8365...
<binary image bytes>ETag is the passport's own payload_hash, and If-None-Match is honoured with a 304, so a client that cached the bytes revalidates against the map instead of downloading again. Four situations answer the same 404 node not found: the node is out of scope, the node does not exist, it has no payload, or the map claims bytes the disk does not have. A remote payload is refused with E_SCHEMA rather than fetched, because a network dependency does not belong inside a read.
Bytes do not enter model material here
Payload bytes are for people. A model reads an image once, at ingest, through the describer that writes the media node's prose, or through the MCP view tool which bounds it at 6 MiB and images only. This route has neither bound because a browser saving a file is not a context window.
Administration
The console needs a surface, not a side channel, so governance is ordinary REST. Every route below requires admin, and where a forest is named, admin on that forest. Two rules run through all of them: a principal only ever sees governance data for forests they administer, and minting or revoking a credential requires admin on every forest that principal holds.
| Route | Methods | What it does |
|---|---|---|
/v1/admin/principals | GET | Principals holding a grant on a forest you administer, with those grants in detail |
/v1/admin/grant | POST | Grant or replace one principal's access to one forest, optionally minting a key in the same call |
/v1/admin/people | GET, POST | Grants, password and keys in one call, so a console can onboard somebody in one form. Each step re-checks its own rule, and a step you may not perform is refused without abandoning the ones you may |
/v1/admin/keys | GET, POST | Mint, list and revoke API keys |
/v1/admin/password | POST | Set or clear a principal's password. The environment super admin has none stored and is refused here |
/v1/admin/audit | GET | The access log, filtered to the forests you administer. limit defaults to 100 and is capped at 500, principal filters by actor |
/v1/admin/forests | POST | Create a forest from {id, title, summary?, seed?}. The id must match ^[a-z0-9][a-z0-9_-]{0,62}$ because it becomes a directory name. seed: "demo" plants a populated example. The creator is granted every capability on it |
/v1/admin/providers | GET, POST | Inference endpoints by name. Secrets go in and never come back out: the listing reports has_key, not the key. Providers declared by the environment cannot be deleted over HTTP |
/v1/admin/providers/test | POST | Probe an endpoint and list the models it offers, before you bind one |
/v1/admin/models | GET, POST | Bind a provider and model to one of four roles per forest: ingest, answer, vision, embed. max_tokens defaults to 600 and reasoning to off |
/v1/admin/canopy | GET, POST | The optional vector index: status, build, incremental refresh, and the per-forest enabled switch. Building re-embeds every summary and the caller waits |
/v1/admin/health | GET | The forest health report: lint errors, orphans, stale nodes. Requires an unrestricted grant, because it counts and names things across the whole forest |
/v1/admin/reindex | POST | Rebuild one forest's catalog from its files. Offered by a read-only Station too: the derived layer is not the content. Requires an unrestricted grant |
/v1/admin/cache | GET, POST | The answer store: enabled, max_entries (default 500), ttl_hours (default null), and clear: true. Clearing costs money, never truth, and the tallies survive it |
/v1/admin/snapshots | GET, POST | List and take git bundles of a forest, optionally with_payloads |
/v1/admin/snapshots/{forest}/{file} | GET | Download one bundle or its payload sidecar. Owner only: a bundle is the whole forest with its whole history, so every branch scope collapses the moment the bytes leave |
/v1/admin/snapshots/import | POST | Multipart upload of a bundle into a forest that does not exist yet. Owner only, and only on a writable registry-mode Station: a bundle bypasses every converter and review that ordinary bytes go through |
The four below are the path from an empty Station to a forest an agent can answer from: create the forest, put the people on it, declare where a model lives, and bind a model to a role. The rest of the table is covered under the remaining admin routes.
Create a forest
/v1/admin/forestsadminCreate an empty forest in the registry root. Registry mode and a writable Station only.
Creation is init_forest and nothing else, so what this produces is byte-identical to what vine init produces on the same machine. The Station adds no second way to make a forest. Authority is admin on an existing forest, or the owner bit, which is the only authority an empty registry can offer. On success the caller is granted every capability on the new forest, because a forest nobody can open is a silent failure with a 200.
Body parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | none | Lowercase letters, digits, - and _, up to 63 characters. An id that already exists is refused with E_SCHEMA rather than returned, because handing back an existing forest on a name collision is an access-control bug wearing a convenience feature. |
title | string | required | none | Becomes the master index heading. |
summary | string | optional | none | Master index summary. Omit it and init writes a sensible default. |
seed | string | optional | null | Omit, or "demo" to plant the sample forest so a fresh console has something to answer. Anything else is E_SCHEMA. A seed that fails halfway removes the forest rather than reporting success with a hole in it. |
Request
curl -sX POST https://station.example.com/v1/admin/forests \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"id": "handbook",
"title": "Company handbook",
"summary": "Policies, onboarding and the support playbook."
}'Response
{
"forest": {
"id": "handbook",
"title": "Company handbook",
"commit": "4f1c2a9e7b30d5482c1a9e7b30d5482c1a9e7b30"
},
"grants": [
{
"forest": "handbook",
"caps": ["admin", "ingest", "query", "read", "tend", "write"],
"allow": [""],
"deny": [],
"tables": {}
}
]
}Two refusals that are not about you
A single-forest Station answers E_SCHEMA (this Station serves a single forest): start it with --root to host more than one. A Station without --writable answers E_READONLY with 403. Neither is a permission problem, and neither is fixable with a better key.
People
/v1/admin/peopleadminEveryone holding a grant on a forest you administer, with their grants, keys and last-seen time.
/v1/admin/peopleadminGrant, revoke, set a password and mint or kill keys, in one call.
Governance shaped like a person rather than like the tables. Grants, passwords and keys are three tables and one thought, so this route applies any combination in one request and the console asks once.
It is a composite, never a new authority. The steps run in a fixed order (grant, revoke access, password, issue key, revoke keys), each re-checks the rule that already governed it, and a step you may not perform is refused without abandoning the ones you may: half a submitted form dropped in silence is worse than either doing it or failing it. Steps that landed come back in applied, the rest in refused with a reason and, for a multi-forest grant, the forest id. The status is 200 whenever anything at all was applied, and 403 only when nothing was.
Body parameters (POST)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
principal | string | required | none | Who this is about. Created by the grant step if they do not exist yet, which is why grant runs first. |
grant | object | optional | none | {forests | forest, caps, allow, deny, tables}. Each forest is authorised, applied and refused on its own: an administrator of two forests out of three grants the two and is told, by id, about the third. Defaults to ["read"] when caps is omitted. |
revoke_access | string | list[str] | optional | none | Forest ids to drop this principal from, under the same per-forest rule. |
password | string | null | optional | none | Set or clear a console password. Requires admin on every forest the target holds (the whole-person rule), and is refused for the environment super admin, whose password lives in MONKEYLLM_STATION_PASSWORD. |
issue_key | bool | object | optional | none | true, or {label, expires_in_days}. The plaintext comes back once as api_key and is never retrievable again. Whole-person rule. |
revoke_keys | bool | list[str] | optional | none | true kills every key this principal holds; a list kills those ids. Ids belonging to somebody else are ignored rather than obeyed. Whole-person rule. |
Request
curl -sX POST https://station.example.com/v1/admin/people \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"principal": "support-bot",
"grant": {
"forests": ["handbook", "tickets"],
"caps": ["read", "query"],
"allow": ["support/"],
"deny": ["support/legal-hold/"]
},
"issue_key": {"label": "support-bot ci", "expires_in_days": 90}
}'Response
{
"principal": "support-bot",
"applied": ["grant", "issue_key"],
"refused": [
{
"step": "grant",
"message": "you do not administer 'tickets'",
"hint": null,
"forest": "tickets"
}
],
"api_key": "mk_L8pQ2vN5wR0kJ7yTcXhBmA4sZfEuD1gOi3rYtV6bNqe"
}On GET, each person carries manageable: false means you administer some of their forests but not all, so their tokens array comes back empty. You can see the person; you cannot touch their credentials.
Providers
/v1/admin/providersadminEvery declared inference endpoint. Never returns a key, only whether one is set.
/v1/admin/providersadminDeclare, edit or remove a provider. Answers with the full list either way.
A provider is any OpenAI-compatible /v1: OpenRouter, LiteLLM, vLLM, a local llama.cpp. The key is write-only. It goes in on a POST and never comes back out, so GET reports has_key and nothing more.
Body parameters (POST)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | required | none | The handle a model binding refers to, e.g. "openrouter" or "local". |
endpoint | string | required | none | Base URL up to and including /v1. A trailing slash is stripped on the way in. |
api_key | string | optional | none | Write-only. Send an empty value to keep the stored one, which is how the console edits an endpoint without ever holding the secret. |
remove | bool | optional | none | Delete the provider named by name, and every model binding that referred to it. |
Request
curl -sX POST https://station.example.com/v1/admin/providers \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"name": "local",
"endpoint": "http://llama:8090/v1",
"api_key": ""
}'Response
{
"providers": [
{
"name": "local",
"endpoint": "http://llama:8090/v1",
"has_key": false,
"origin": "console",
"created": "2026-08-15T09:41:12+00:00"
}
]
}Environment providers are read-only here
A provider whose origin is env was declared by the deployment and its key is never stored in the registry: it is read from the environment at call time. Editing or removing one answers E_SCHEMA telling you to change the variables and restart the Station.
/v1/admin/providers/testadminConnection test and catalogue in one call: does this endpoint answer, and what does it serve.
Send name to test a stored provider, or endpoint and api_key to test one before declaring it. Prices come back when the provider states them; a local Ollama or llama.cpp states none, and silence is reported as silence rather than as zero.
{
"ok": true,
"count": 2,
"models": [
{ "id": "qwen3-30b-a3b", "name": null,
"prompt": null, "completion": null, "context": 32768 },
{ "id": "qwen3-embedding-0.6b", "name": null,
"prompt": null, "completion": null, "context": 8192 }
]
}Model bindings
/v1/admin/modelsadminBindings for one forest via ?forest=<id>, or every binding this caller administers.
/v1/admin/modelsadminBind or unbind one role on one forest. Answers with that forest's bindings.
A binding says which model plays which role for one forest. The four roles are ingest (curation summaries), answer, vision and embed. An unbound role is not an error: the stage it drives is simply skipped, so a forest with no ingest binding adopts with mechanical summaries and a forest with no answer binding refuses POST /v1/forests/{forest}/answer.
Body parameters (POST)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
forest | string | required | none | Which forest this binding is for. Needs admin on that forest. |
role | string | required | none | ingest, answer, vision or embed. Anything else is E_SCHEMA. |
provider | string | required | none | A name from /v1/admin/providers. An unknown provider is E_SCHEMA: a binding cannot invent its own endpoint. |
model | string | required | none | The model id as the provider spells it, e.g. "qwen3-30b-a3b". |
max_tokens | int | optional | 600 | Reply budget for this role. |
reasoning | string | optional | "off" | "on" or "off". Anything that is not "on" stores as "off". |
remove | bool | optional | none | Unbind role on forest instead of writing it. |
Request
curl -sX POST https://station.example.com/v1/admin/models \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"forest": "handbook",
"role": "answer",
"provider": "local",
"model": "qwen3-30b-a3b",
"max_tokens": 600,
"reasoning": "off"
}'Response
{
"bindings": [
{
"forest": "handbook",
"role": "answer",
"provider": "local",
"model": "qwen3-30b-a3b",
"max_tokens": 600,
"reasoning": "off"
},
{
"forest": "handbook",
"role": "ingest",
"provider": "local",
"model": "qwen3-30b-a3b",
"max_tokens": 600,
"reasoning": "off"
}
]
}Granting access
Request
curl -sX POST https://station.example.com/v1/admin/grant \
-H "Authorization: Bearer $MONKEYLLM_KEY" \
-H 'content-type: application/json' \
-d '{
"principal": "reporting-bot",
"forest": "handbook",
"caps": ["read", "query"],
"allow": ["policies/"],
"deny": ["policies/legal-hold/"],
"tables": {"finance/ledger-2026": ["entries"]},
"issue_key": true
}'Response
{
"principal": "reporting-bot",
"grants": [
{
"forest": "handbook",
"caps": ["query", "read"],
"allow": ["policies/"],
"deny": ["policies/legal-hold/"],
"tables": { "finance/ledger-2026": ["entries"] }
}
],
"api_key": "mk_L8pQ2vN5wR0kJ7yTcXhBmA4sZfEuD1gOi3rYtV6bNqe"
}An unknown capability answers E_SCHEMA naming it. allow and deny are branch prefixes and are normalised with a trailing slash on the way in. tables maps a dataset node id to the tables the principal may name in SQL.
Reading the audit log
curl -s "https://station.example.com/v1/admin/audit?limit=50&principal=reporting-bot" \
-H "Authorization: Bearer $MONKEYLLM_KEY"{
"entries": [
{
"ts": "2026-08-15T09:41:12+00:00",
"principal": "reporting-bot",
"forest": "handbook",
"primitive": "answer",
"args": "{\"question\": \"what is our expense policy?\", \"k\": \"3\"}",
"result": "ok",
"size": 4821,
"commit_sha": null
}
]
}Arguments are digested, never stored verbatim: any value over 80 characters becomes <n chars>. The log records who read what, not the content they read. Writes additionally carry commit_sha, and every write commit inside the forest is stamped with the acting principal, so which agent read which nodes in which order is reconstructible after the fact.
The remaining admin routes
The rest of the table above are operational rather than structural, and they share one shape: GET reads the current state, POST changes it and answers with the same object the GET would have returned, so a console never has to re-fetch.
GET /v1/admin/principalsandGET /v1/admin/keysare the read side of/v1/admin/people, split by table for a console that wants one or the other.POST /v1/admin/keysmints and revokes under the same whole-person rule.POST /v1/admin/passwordsets or clears one principal's console password, again whole-person.GET /v1/admin/health?forest=<id>relays the Ranger's report for one forest. It needs anadmingrant that is not limited to a branch, because the report counts lint errors and names nodes everywhere: a scoped principal served a filtered version would be reading numbers that quietly describe nodes they may not see.GET /v1/healthis the unauthenticated liveness check and is not this route.GET, POST /v1/admin/canopyandGET, POST /v1/admin/cacheread and change the optional vector layer and the answer cache for one forest. Building the Canopy re-embeds every summary and the caller waits, deliberately: a fire-and-forget build would leave the console unable to say whether the index it is about to rely on exists.POST /v1/admin/reindexrebuilds_derived/catalog.dbfrom the files. It is offered by a read-only Station too, because_derived/is not the content: rebuilding it plants nothing and commits nothing.- The three
/v1/admin/snapshotsroutes are owner-only, for the reason given in the table: a bundle is the whole forest with its whole history, so every branch scope collapses the moment the bytes leave.
Check the method before you debug the key
Several admin routes are POST-only. A GET to one of them answers 404 with no such endpoint rather than 405, which reads like a missing route and sends people looking at their key. The Methods column above is the authority.
Budgets, truncation and rate limits
There is no cursor pagination anywhere in this API, and that is deliberate. Every read answers within a declared token budget, and a cut result always says truncated: true. A caller that sees it should ask narrower, not retry harder.
| Call | Budget | Other bounds |
|---|---|---|
look | 500 tokens | 12 edges each way, 25 tokens per neighbour summary |
move | 600 tokens | Whole neighbours dropped from the tail, never sliced |
locate | 800 tokens | k results |
scan | 800 tokens | limit clamped to 50 |
sniff | 800 tokens | k clamped to 20, 8 terms, 3 matches per node |
query | 2000 tokens | LIMIT 200 injected, 2 second deadline |
pick | 4000 tokens | Over budget returns the outline plus a hint |
harvest | 4000 tokens | k capped at 5 by default, 1200 tokens and 2 sections per node |
Other bounds worth knowing:
- Map projections default to 2000 rows and cap at 10000.
- The audit log caps
limitat 500. - Job records keep the 20 most recent per forest.
- The MCP
viewtool refuses images over 6 MiB. The payload route has no such bound.
Rate limits
There is no rate limit on key-authenticated routes. The two password doors, /v1/auth/login and /v1/auth/pair, share one fixed-window counter: 5 failures per 60 seconds per combination of username and client host. Past the limit the answer costs no password comparison at all:
HTTP/1.1 429 Too Many Requests
{
"error": {
"code": "E_FORBIDDEN",
"message": "too many attempts; try again shortly"
}
}The same message appears whether the username exists or not, so the limiter cannot become the directory the login refusal already refuses to be. A successful sign-in clears the window. The counter lives in process, so a restart forgets it.