Skip to content
MonkeyLLMDocs

Connect

REST API

The Station serves every forest it hosts over plain HTTP and JSON under /v1. It is the same surface the Studio console uses: there is no privileged side channel, so whatever the console shows you, a client holding the same key can fetch.

On this page(43)

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.

textBase URL
https://station.example.com/v1

Run 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:

bash
station serve --root /forests --registry /registry/station.db \
  --host 0.0.0.0 --port 8800 --writable

Every 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. /v1 does not change meaning underneath a client that already works.
  • Error codes are stable, messages are not. Branch on error.code (see Errors). Never match on error.message or error.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, the RateLimit-* 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.

bash
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:

json
{
  "error": {
    "code": "E_FORBIDDEN",
    "message": "missing or invalid API key"
  }
}

Claiming a new Station

POST/v1/auth/setup

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

NameTypeRequiredDefaultDescription
usernamestringrequirednoneThe owner principal id.
passwordstringrequirednoneAt least 12 characters. This one credential governs every forest, present and future.
emailstringoptionalnoneOptional, stored on the principal record.

Request

bash
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

json
{
  "key": "mk_9tK2wQ8pv1RfL0nYcXhA6ZbM4sEuD3gJ7iOaVrT5kNw",
  "principal": "jimmy",
  "expires_at": "2026-08-16T09:41:12+00:00",
  "admin": true,
  "owner": true
}

Sessions from a password

POST/v1/auth/login

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

NameTypeRequiredDefaultDescription
usernamestringrequirednonePrincipal id.
passwordstringrequirednoneThe stored password, or the environment super admin password.

Request

bash
curl -sX POST https://station.example.com/v1/auth/login \
  -H 'content-type: application/json' \
  -d '{"username": "jimmy", "password": "a-long-enough-passphrase"}'

Response

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

POST/v1/auth/pair

Self-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 for write, tend, query or admin is refused with E_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 0 means the default, never unlimited, and a request over the ceiling is told the ceiling rather than silently clamped.
NameTypeRequiredDefaultDescription
usernamestringrequirednonePrincipal id.
passwordstringrequirednoneThe same password login takes.
capsstring[]optional["read","ingest"]Must be a subset of read and ingest. An empty list or null means the default.
expires_in_daysnumberoptional90Positive and finite, at most 365. Absent or 0 means 90.
labelstringoptional"clipper"Shown in the Access console so a human can tell keys apart.

Request

bash
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

json
{
  "api_key": "mk_D5vN8hZq2WcJ1yLpXeT7bR0mKsA4gFuI6oQnV3rYtBd",
  "principal": "jimmy",
  "caps": ["ingest", "read"],
  "expires_at": "2026-11-13T09:41:12+00:00"
}

Minting and revoking keys

GET/v1/admin/keysadmin

List key metadata for every principal you fully administer. Never the secret: only its digest, prefix and status.

POST/v1/admin/keysadmin

Mint 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

NameTypeRequiredDefaultDescription
principalstringrequirednoneWho the key is for. Required unless you are revoking.
labelstringoptionalnoneFree text shown in listings.
expires_in_daysnumberoptionalnoneAbsent, empty or 0 mints a key with no expiry. Unlike a paired key, an administrator mint has no ceiling.
revokestringoptionalnoneA key id (its digest, from the GET listing). When present the call revokes instead of minting and answers {revoked, principal}.

Request

bash
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

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

Capabilities
CapabilityUnlocks
readlocate, look, move, pick, scan, sniff, harvest, answer, the map projections and the payload route
queryquery: read-only SQL over a dataset node
writeplant, graft, curate
tendtend: single-statement dataset writes
ingestingest and the job routes that watch a batch
adminEverything 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.

json
{
  "error": {
    "code": "E_FORBIDDEN",
    "message": "'tend' requires the 'tend' capability",
    "hint": "This principal holds: ['ingest', 'read']."
  }
}

The code maps onto the HTTP status:

Error codes and HTTP status
CodeStatusMeans
E_NOT_FOUND404No such node, forest, section, job or endpoint. Also: out of scope.
E_SCHEMA400The request was malformed, or an argument was wrong.
E_FRONTMATTER400A node passport failed validation on a write.
E_FORBIDDEN403The principal is missing a capability, or is writing outside its grant. Returned with 401 when the key itself is missing or invalid.
E_READONLY403This Station serves read-only forests.
E_QUERY_FORBIDDEN403The SQL guard refused: not a dataset, more than one statement, a forbidden keyword, or a table the grant does not permit.
E_QUERY_INVALID400SQLite refused the statement itself, a mistyped table or column. Retrying with a corrected statement is the right move.
E_LOCKED409A batch is already running on this forest, or a writer lock is held.
E_TIMEOUT504A 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

GET/v1/health

Liveness and the two facts a sign-in screen needs. The only route that takes no key.

Request

bash
curl -s https://station.example.com/v1/health

Response

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

GET/v1/me

The calling principal, its grants as policy resolves them, and where each grant starts.

Request

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

Response

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

GET/v1/forests

Every forest this key may use, with its capabilities and starting roots.

Request

bash
curl -s https://station.example.com/v1/forests \
  -H "Authorization: Bearer $MONKEYLLM_KEY"

Response

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

text
POST /v1/forests/{forest}/{primitive}

The names this route serves:

Primitive routes by group
GroupNamesNeeds
Readlocate, look, move, pick, scan, sniff, harvestread
Dataset readqueryquery
Writeplant, graftwrite
Dataset writetendtend
Compositeanswerread plus a model bound to the answer role
Compositecuratewrite plus a model bound to the ingest role
Host actioningestingest

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, default false) 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-Timing response 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.
    http
    Server-Timing: vine;dur=62.4, model;dur=1840.2, cache;dur=1.8, host;dur=9.1

    vine is the engine, model the provider round trip when there was one, cache the answer store when it was consulted, and host whatever 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

POST/v1/forests/{forest}/locateread

Ranked entry points over curated metadata. Where to drop into the forest.

NameTypeRequiredDefaultDescription
querystringrequirednoneFree text. Matched against titles, summaries, tags and aliases, not bodies.
kintegeroptional5How many results to return.
scopestringoptional"all"One of all, branches, bananas. Branches are index nodes, bananas are leaf nodes.
type_filterstringoptionalnoneKeep 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"}'
json200 OK
{
  "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

POST/v1/forests/{forest}/lookread

A cheap digest of one node: summary, edges, children or outline, and stats. Read this before paying for a body.

NameTypeRequiredDefaultDescription
idstringrequirednoneThe node id.
fieldsstring[]optionalnoneReturn only these keys (id is always included). Cheaper on dataset nodes, where query_manual, sample_rows and notes each open the payload.
gauntletbooleanoptionalnoneRank the frontier toward the current goal before it is cut. Follows the forest setting when omitted.
towardstringoptionalnoneRank the frontier toward this text instead of the goal remembered from the last locate.

Request

bash
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

json
{
  "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, coverage and, 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_rows and the operator notes in notes. 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

POST/v1/forests/{forest}/moveread

The neighbours of a node along typed edges, or the physical children of a branch.

NameTypeRequiredDefaultDescription
idstringrequirednoneThe node to move from.
relstringoptionalnoneKeep only this relation. The special value "children" lists a branch's physical children instead of its edges.
directionstringoptional"out"out, in or both. Inbound edges are shown under their inverse relation, as the forest's dialect defines it.
gauntletbooleanoptionalnoneAs on look.
towardstringoptionalnoneAs on look.

Request

bash
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

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

POST/v1/forests/{forest}/pickread

The body of a node, or one section of it. The expensive call, taken only once the digest says this is the one.

NameTypeRequiredDefaultDescription
idstringrequirednoneThe node id.
sectionstringoptionalnoneA header from the node's outline. An unknown header answers E_NOT_FOUND with the available sections in the hint.

Request

bash
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

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

json200 OK, truncated
{
  "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

POST/v1/forests/{forest}/scanread

A metadata query over a branch, answered from the catalog with no file opens.

NameTypeRequiredDefaultDescription
parent_idstringrequirednoneThe branch to scan, e.g. policies/_index.
filterobjectoptionalnoneAny passport column, plus tags_any, updated_after, updated_before, created_after and min_confidence. An unknown key answers E_SCHEMA.
fieldsstring[]optional["id","type","summary"]Which columns each row carries back.
recursivebooleanoptionalfalseWalk the whole subtree instead of the direct children.
limitintegeroptional50Clamped to the range 1 to 50.
gauntletbooleanoptionalnoneAs on look.
towardstringoptionalnoneAs on look.

Request

bash
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

json
{
  "nodes": [
    { "id": "policies/expenses", "title": "Expense policy", "updated": "2026-07-30" },
    { "id": "policies/travel", "title": "Travel booking", "updated": "2026-03-02" }
  ],
  "truncated": false
}

sniff

POST/v1/forests/{forest}/sniffread

Literal search inside bodies: the exact codes, names and numbers that summaries do not carry.

NameTypeRequiredDefaultDescription
termsstring | string[]requirednoneOne to 8 literal terms, each at least 2 characters after normalisation. Regular expressions are not supported.
scopestringoptionalnoneA branch id narrows to that subtree; a leaf node id greps inside that node alone. Omit it to sweep the whole visible forest.
kintegeroptional5Clamped to a maximum of 20.
type_filterstringoptionalnoneRestrict to one node type.

Request

bash
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

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

POST/v1/forests/{forest}/harvestread

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

NameTypeRequiredDefaultDescription
querystringrequirednoneThe free-text question.
termsstring[]optionalnoneLiteral terms for the sniff half. Derived from the query when omitted: words of 4 characters or more, minus stopwords, capped at 8.
kintegeroptional3How 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

bash
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

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

POST/v1/forests/{forest}/answerread

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

NameTypeRequiredDefaultDescription
questionstringrequirednoneThe question. query is accepted as an alias.
kintegeroptional3How many nodes the retrieval half brings back.
cachebooleanoptionaltruePass false to skip the forest's answer store and buy a fresh run, which then replaces the stored one.
reply_tokensintegeroptionalnoneBound this reply, clamped to the range 64 to 4000. Absent or 0 lets the forest binding decide.
hopsboolean | integeroptionalnoneLet 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.
hybridbooleanoptionalfalseFuse 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}'
json200 OK
{
  "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:

json200 OK, served from the store
{
  "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

POST/v1/forests/{forest}/queryquery

Read-only SQL against a dataset node's SQLite payload.

NameTypeRequiredDefaultDescription
idstringrequirednoneA node of type dataset with a sqlite payload. Anything else answers E_QUERY_FORBIDDEN.
sqlstringrequirednoneA single statement starting with SELECT or WITH. Semicolons beyond a trailing one are refused, as are write and DDL keywords.

Request

bash
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

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

POST/v1/forests/{forest}/plantwrite

Create a node: file, index entry and git commit, atomically.

NameTypeRequiredDefaultDescription
nodeobjectrequirednoneThe 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

bash
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

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

POST/v1/forests/{forest}/graftwrite

Edit a node in one atomic patch. Summary changes propagate verbatim to every index that replicates them.

NameTypeRequiredDefaultDescription
idstringrequirednoneThe node to edit.
patchobjectrequirednoneAny combination of set_frontmatter, add_links, remove_links, append_section, replace_section and replace_body. An empty patch answers E_SCHEMA.

Request

bash
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

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

POST/v1/forests/{forest}/tendtend

A single-statement write into a dataset payload, committed with an audit reference.

NameTypeRequiredDefaultDescription
idstringrequirednoneThe dataset node.
sqlstringrequirednoneOne INSERT, UPDATE or DELETE. WHERE is mandatory on UPDATE and DELETE. DDL is never allowed.

Request

bash
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

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

POST/v1/forests/{forest}/curatewrite

Re-summarise one node with the model bound to the ingest role. Proposes: it does not write.

NameTypeRequiredDefaultDescription
idstringrequirednoneThe node to re-curate.

Request

bash
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

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

POST/v1/forests/{forest}/ingestingest

Put documents into the forest through the Gardener: converters, curation and commits, exactly as an operator gets them.

NameTypeRequiredDefaultDescription
modestringoptional"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.
filesobject[]optionalnoneRequired 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.
deststringoptionalnoneThe 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.
pathstringoptionalnoneFor 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.
sourcestringoptionalnoneThe source directory. Same authority rules as path.
waitbooleanoptionalfalseHold the response open until the batch finishes and return the completed job. Otherwise the call answers 202 immediately.
titlestringoptionalnonecompose only. Names the node and its file.
textstringoptionalnonecompose only. The markdown body.
stagebooleanoptionalfalsecompose only. Preview the draft the Gardener would plant, without planting it.
draftobjectoptionalnonecompose only. A reviewed draft from a previous stage call, accepted as written. A request may stage or accept, never both.

Request

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

GET/v1/forests/{forest}/ingestingest

What a refresh would re-read, before anyone asks for one.

json200 OK
{
  "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.

GET/v1/forests/{forest}/jobsingest

The jobs this Station remembers for the forest, newest first.

GET/v1/forests/{forest}/jobs/{job}ingest

One job, with its report once it has finished.

POST/v1/forests/{forest}/jobs/{job}/cancelingest

Ask the driver to stop at the next document boundary. A no-op on a finished job, never an error.

jsonGET .../jobs/ing-4b7f21ac
{
  "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 }
    }
  }
}
Ingest job fields
FieldMeaning
staterunning, then one of done, error or cancelled
done / totalDocuments finished, and how many the walk found
current / stageThe document being processed and the phase it is in, so a batch of one large file shows movement instead of standing still
errorsHow many documents failed. A batch does not stop on one bad file
reportPresent 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.

GET/v1/forests/{forest}/graphread

Nodes and typed edges as a graph, with degree and heat per node.

GET/v1/forests/{forest}/trailsread

Accumulated pheromone per node, ranked, with summary statistics.

Query parameters

NameTypeRequiredDefaultDescription
scopestringoptionalnoneA branch to restrict to. Both projects/_index and the bare projects name the same region; empty or _index means the whole visible forest.
limitintegeroptional2000Clamped to the range 1 to 10000.

Request

bash
curl -s "https://station.example.com/v1/forests/handbook/graph?scope=policies&limit=500" \
  -H "Authorization: Bearer $MONKEYLLM_KEY"

Response

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

jsonGET .../trails
{
  "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

GET/v1/forests/{forest}/payload/{node}read

The 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

bash
curl -s -o screenshot.png -D - \
  "https://station.example.com/v1/forests/handbook/payload/media/onboarding-screenshot" \
  -H "Authorization: Bearer $MONKEYLLM_KEY"

Response

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

Administration routes
RouteMethodsWhat it does
/v1/admin/principalsGETPrincipals holding a grant on a forest you administer, with those grants in detail
/v1/admin/grantPOSTGrant or replace one principal's access to one forest, optionally minting a key in the same call
/v1/admin/peopleGET, POSTGrants, 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/keysGET, POSTMint, list and revoke API keys
/v1/admin/passwordPOSTSet or clear a principal's password. The environment super admin has none stored and is refused here
/v1/admin/auditGETThe access log, filtered to the forests you administer. limit defaults to 100 and is capped at 500, principal filters by actor
/v1/admin/forestsPOSTCreate 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/providersGET, POSTInference 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/testPOSTProbe an endpoint and list the models it offers, before you bind one
/v1/admin/modelsGET, POSTBind 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/canopyGET, POSTThe optional vector index: status, build, incremental refresh, and the per-forest enabled switch. Building re-embeds every summary and the caller waits
/v1/admin/healthGETThe forest health report: lint errors, orphans, stale nodes. Requires an unrestricted grant, because it counts and names things across the whole forest
/v1/admin/reindexPOSTRebuild 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/cacheGET, POSTThe 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/snapshotsGET, POSTList and take git bundles of a forest, optionally with_payloads
/v1/admin/snapshots/{forest}/{file}GETDownload 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/importPOSTMultipart 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

POST/v1/admin/forestsadmin

Create 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

NameTypeRequiredDefaultDescription
idstringrequirednoneLowercase 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.
titlestringrequirednoneBecomes the master index heading.
summarystringoptionalnoneMaster index summary. Omit it and init writes a sensible default.
seedstringoptionalnullOmit, 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

bash
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

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

GET/v1/admin/peopleadmin

Everyone holding a grant on a forest you administer, with their grants, keys and last-seen time.

POST/v1/admin/peopleadmin

Grant, 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)

NameTypeRequiredDefaultDescription
principalstringrequirednoneWho this is about. Created by the grant step if they do not exist yet, which is why grant runs first.
grantobjectoptionalnone{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_accessstring | list[str]optionalnoneForest ids to drop this principal from, under the same per-forest rule.
passwordstring | nulloptionalnoneSet 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_keybool | objectoptionalnonetrue, or {label, expires_in_days}. The plaintext comes back once as api_key and is never retrievable again. Whole-person rule.
revoke_keysbool | list[str]optionalnonetrue 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

bash
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

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

GET/v1/admin/providersadmin

Every declared inference endpoint. Never returns a key, only whether one is set.

POST/v1/admin/providersadmin

Declare, 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)

NameTypeRequiredDefaultDescription
namestringrequirednoneThe handle a model binding refers to, e.g. "openrouter" or "local".
endpointstringrequirednoneBase URL up to and including /v1. A trailing slash is stripped on the way in.
api_keystringoptionalnoneWrite-only. Send an empty value to keep the stored one, which is how the console edits an endpoint without ever holding the secret.
removebooloptionalnoneDelete the provider named by name, and every model binding that referred to it.

Request

bash
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

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

POST/v1/admin/providers/testadmin

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

json200 OK
{
  "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

GET/v1/admin/modelsadmin

Bindings for one forest via ?forest=<id>, or every binding this caller administers.

POST/v1/admin/modelsadmin

Bind 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)

NameTypeRequiredDefaultDescription
foreststringrequirednoneWhich forest this binding is for. Needs admin on that forest.
rolestringrequirednoneingest, answer, vision or embed. Anything else is E_SCHEMA.
providerstringrequirednoneA name from /v1/admin/providers. An unknown provider is E_SCHEMA: a binding cannot invent its own endpoint.
modelstringrequirednoneThe model id as the provider spells it, e.g. "qwen3-30b-a3b".
max_tokensintoptional600Reply budget for this role.
reasoningstringoptional"off""on" or "off". Anything that is not "on" stores as "off".
removebooloptionalnoneUnbind role on forest instead of writing it.

Request

bash
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

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

bash
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

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

bash
curl -s "https://station.example.com/v1/admin/audit?limit=50&principal=reporting-bot" \
  -H "Authorization: Bearer $MONKEYLLM_KEY"
json200 OK
{
  "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/principals and GET /v1/admin/keys are the read side of /v1/admin/people, split by table for a console that wants one or the other. POST /v1/admin/keys mints and revokes under the same whole-person rule.
  • POST /v1/admin/password sets 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 an admin grant 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/health is the unauthenticated liveness check and is not this route.
  • GET, POST /v1/admin/canopy and GET, POST /v1/admin/cache read 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/reindex rebuilds _derived/catalog.db from 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/snapshots routes 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.

Budgets and other bounds per call
CallBudgetOther bounds
look500 tokens12 edges each way, 25 tokens per neighbour summary
move600 tokensWhole neighbours dropped from the tail, never sliced
locate800 tokensk results
scan800 tokenslimit clamped to 50
sniff800 tokensk clamped to 20, 8 terms, 3 matches per node
query2000 tokensLIMIT 200 injected, 2 second deadline
pick4000 tokensOver budget returns the outline plus a hint
harvest4000 tokensk 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 limit at 500.
  • Job records keep the 20 most recent per forest.
  • The MCP view tool 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
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.

Next steps

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