Errors and rate limits
Errors and rate limits
Two envelopes
Errors come back in one of two shapes, depending on where they happen.
1. Protocol / auth failures — malformed JSON, an unknown method, bad params — use the JSON-RPC error envelope, which carries a numeric code:
{
"jsonrpc": "2.0",
"id": 1,
"error": {"code": -32602, "message": "Invalid params"}
}
2. A failure inside a tool call comes back as a normal tools/call result with isError: true and a stable, machine-dispatchable kind (no numeric code), plus any extra structured fields alongside it:
{
"content": [{"type": "text", "text": "File is being edited by the user; try again later"}],
"isError": true,
"kind": "target-is-being-edited",
"retry_after_ms": 2000,
"since": "2026-05-29T17:00:00Z"
}
Dispatch on code for protocol errors and on kind for tool errors. message is for humans, never for branching.
Authentication & authorisation
A missing, malformed, unknown, revoked, or expired token is rejected at the transport layer with HTTP 401 (Missing or invalid API key). The server does not distinguish the reason, so there’s no per-reason kind — just the 401. Refresh / rotate the token; none of these are transient.
Authorisation failures (a valid token that lacks access) come back as tool-result errors carrying a kind:
kind |
Description |
|---|---|
insufficient-scope |
Tool requires more scope (e.g. write) than the token has |
folder-not-allowed |
A path argument is outside the token’s folder allowlist |
Tool-result error kinds
Returned in the tools/call result envelope (isError: true, kind: …). These do not carry a JSON-RPC code — dispatch on kind. This is the complete set:
kind |
Raised when | Extra data |
|---|---|---|
args-invalid |
Tool arguments don’t match the schema — a required field is missing, the wrong type, or an empty/non-array where one is required | |
path-invalid |
A path is malformed: absolute, contains .., or escapes the vault (path-traversal attempt) |
|
note-not-found |
path doesn’t resolve to a note |
|
note-exists |
write_note with mode: create, but the file already exists |
|
not-markdown |
path isn’t a Markdown file |
|
confirmation-required |
The op needs confirm: true (e.g. delete_note) |
|
target-not-found |
A patch_section / apply_edits target (heading, block id, or frontmatter key) didn’t match |
|
edit-not-unique |
An apply_edits find matched zero or multiple times — the whole call rolled back |
matches (Int) |
target-is-being-edited |
A write was blocked by the editor soft-lock (the user has the note open) | retry_after_ms, since |
immutable-path |
The target is under an immutable prefix (e.g. raw sources/) — create or append a new file instead |
|
forbidden |
A folder-scoped key called a tool that writes outside its allowlist with no path argument to gate on — e.g. ingest_url / ingest_file, which always write to the sources/ layer |
|
proposal-stale |
A queued proposal’s target drifted (content changed, or the path now exists) and can no longer apply cleanly | |
unsupported |
The tool name isn’t recognised, or it’s a folder op a propose-only key can’t use in Suggestions mode | |
internal |
An unhandled server error — please file a bug, with the message |
…plus the two authorisation kinds above (insufficient-scope, folder-not-allowed).
The kind set is deliberately small and stable. A target mismatch — heading, block id, frontmatter key, or a directive line — surfaces uniformly as target-not-found; a path that’s absolute, contains .., or escapes the vault surfaces as path-invalid. Branch on these classes, not on finer distinctions the wire doesn’t make.
Standard JSON-RPC errors
Protocol-level failures use the JSON-RPC error envelope with these codes:
code |
Meaning |
|---|---|
-32700 |
Parse error (request wasn’t JSON) |
-32600 |
Invalid request (missing jsonrpc or method) |
-32601 |
Method not found |
-32602 |
Invalid params |
An unhandled server-side error surfaces as the internal tool-result kind (above) rather than a protocol -32603.
Rate limits
The paid AI tools — the ones that spend on the user’s LLM/embeddings key (semantic_search, answer, related_notes, find_duplicates, reindex_embeddings, topic_map, eval, lint_semantic, wiki_maintain, suggest_tags, ingest_url, ingest_file) — share a generous per-key sliding-window limit (60 calls/minute per identity). It exists as a runaway-loop backstop, not a throughput cap: a normal interactive agent never trips it. Exceeding it returns the rate-limited tool-result kind with a retry_after_ms hint. All other tools are unlimited — the server runs locally and your machine is the limit.
Retry policy guidance
| Error class | Strategy |
|---|---|
Auth (401, insufficient-scope, folder-not-allowed) |
Don’t retry. Surface to the user. |
Not found (note-not-found, target-not-found) |
Don’t retry as-is. Re-list / re-read to rediscover, then re-issue. |
Validation (args-invalid, path-invalid, not-markdown, confirmation-required, immutable-path) |
Don’t retry. Fix the call. |
edit-not-unique |
Re-read the note, rebuild the find anchors, retry (up to ~3×). |
proposal-stale |
Re-read the target, then re-issue the proposal. |
target-is-being-edited |
Retry after retry_after_ms — the user is editing; back off, up to ~10×. |
rate-limited |
Back off retry_after_ms, then retry. You’re in a loop if you see this twice — slow down. |
internal |
Retry once. If it persists, report it with the full message. |
Related
Agent reference/02 MCP transport — the wire shape
Agent reference/07 Concurrency and soft-locks — when to retry