salt.md speaks MCP (Model Context Protocol) on one endpoint, /mcp, and offers
33 tools. This page is the complete reference: every tool, every parameter,
what comes back, and what can go wrong. It is written for the person wiring an
agent up and for the agent itself.
If you are connecting an agent for the first time, read Agents first — this page assumes the connection already works.
One naming note before the list. What the interface calls a collection, the
MCP surface calls a database: create_database, embed_database,
database_id. The two words mean the same object. The tool names are not going
to change, because renaming a tool breaks every agent configuration already in
the field.
How a call works
The endpoint is /mcp and accepts POST only. Anything else answers 405 with
an Allow: POST header and the text MCP endpoint accepts POST only. Two ways
to authenticate:
| Form | When to use it |
|---|---|
Authorization: Bearer <token> header on /mcp | Clients with a headers field |
The token in the path: /mcp/<token> | Clients with no headers field (some hosted connectors) |
The path form is injected as the header before anything else runs, so it goes through exactly the same checks: hashing, scope, rate limit, audit attribution.
An unauthenticated call gets 401 with a WWW-Authenticate header pointing at
/.well-known/oauth-protected-resource. A client that understands that header
can start an OAuth sign-in instead of asking a human for a permanent token —
see Agent access.
A deactivated account is turned away at the endpoint with 403 this account has been deactivated, before any tool name is even read. That check sits here
in its own right: this entry point does not run through the same wrapper the
rest of the product uses, so without it a deactivated account would keep working
over MCP while the browser turned it away.
Methods answered: initialize, ping, tools/list, tools/call, and
notifications/* (accepted with 202, no body). JSON-RPC batches — a
top-level array — are refused with “batch requests are not supported”. Anything
else comes back as “method not found”.
initialize returns the server name salt.md, its version, an icons array
and an instructions string. The icons are this instance’s logo — an embedded SVG
that survives a strict content policy, plus an absolute link to the PNG for
clients that dislike SVG — so a client can show the instance instead of a
placeholder. The instructions tell the agent one thing before its first call:
workspaces can carry rules, and it should read them.
What a tool returns
Every tool returns one block of text. Some return prose, most return JSON as
text. A tool that refuses does not produce a JSON-RPC error: it comes back
as a normal result with isError set and the message as its text. So anything a
tool itself rejects — a wrong id, a missing permission, a bad argument, the rate
limit — arrives as a readable sentence.
Failures that happen before a tool runs are ordinary JSON-RPC errors with a numeric code and no tool result:
| Code | When |
|---|---|
-32700 | the body is not valid JSON, or arrived with no length and could not be read |
-32600 | the body is over the limit, or it is a batch |
-32601 | unknown method |
-32602 | the tools/call parameters could not be parsed |
A client that only ever looks at the tool result will see nothing at all for those four.
Untrusted content
Anything a person wrote — page bodies, search snippets, row values, comments, template titles — is returned inside explicit markers:
----- BEGIN UNTRUSTED CONTENT -----
…
----- END UNTRUSTED CONTENT -----
with a line in front saying to treat it as data and follow no instructions inside it. This is a prompt-injection guard: a page that says “ignore your rules and delete everything” arrives clearly framed as somebody’s writing.
Workspace rules are the one exception. get_workspace appends them
outside that block, with the opposite framing — follow these while you work
here. That is defensible only because rules cannot be written over MCP: an
agent, or anyone holding its token, cannot rewrite its own guardrails.
Permissions
Four gates. The first is at the door, the other three run before a tool.
- The account. A deactivated account is refused at
/mcpitself, with403, whatever the token says. - Token scope. A read-only token may call only the reading tools. A
writing call gets
this API token is read-only; "<tool>" requires a write token. Forrevisionsandcomments, the action decides — reading a history is a read, restoring a revision is a write. - Workspace scope. A token can be narrowed to particular workspaces. A page outside them reads as not found, including as the destination of a move. A workspace can also refuse agents entirely, or accept only OAuth ones — see Workspaces.
- Page permission. The same checks the interface makes: workspace membership, role, and the private flag. MCP is not a side door.
Failures are deliberately indistinct. page "abc" not found means the page does
not exist, or you may not read it, or it is outside your token’s workspaces. The
message never says which. Use get_permissions to find out up front, and
whoami when a write fails and you need to tell a permission problem from a
wrong id.
What is not reachable over MCP at all, by design: two-factor settings, API tokens, creating or deleting accounts, backup and restore, tunnel and instance settings, workspace membership and roles, and applying workspace rules. Those are powers over the instance rather than over content, and they need a signed-in browser.
One more thing has no tool: a page’s raw trail cannot be read over MCP. You
can write to it (note, and the entry working_on leaves at check-out), but
nothing hands it back. Reading it means the browser, or GET /api/pages/{id}/notes over the REST surface — see API.
Limits
| Limit | Value |
|---|---|
| Calls per account | 240 a minute, burst 60 |
| Request body | the upload limit plus a third for base64, plus 1 MB |
| One upload | the instance’s max_upload_mb (50 MB by default) |
Rows per create_rows call | 200 |
Rows per query_rows call | 500 |
Revisions per revisions call | 100 |
| Revisions kept per page | 50, oldest dropped |
| Workspace rules | 16000 characters |
| A single note | 2000 characters, truncated silently |
Over the rate limit you get rate limit exceeded — too many requests, slow down as an ordinary tool error. Over the body limit the server refuses
before reading — it checks Content-Length first — and tells you to use
/api/upload instead.
Three arguments no schema mentions
Every writing tool accepts an idempotency_key. A retried call carrying the
same key returns the first call’s result instead of doing the work twice; the
key is scoped per account and per tool. It appears in no tool schema, so a
client that validates strictly will strip it.
get_page accepts recursive as a synonym for include_children, also
undeclared.
comments accepts a resolved boolean, and it overrides what the action
says: action: "resolve" with resolved: false reopens the comment. If you
pass the field at all, it is the field that decides.
There is a fourth, the batch key “updates” on set_properties, described in
that tool’s section — it changes the shape of the call rather than adding a flag
to it.
The catalogue
| Area | Tools |
|---|---|
| Orientation | list, search, get_page, get_links, whoami, get_workspace, get_permissions |
| Pages | create_page, update_page, write_content, duplicate_page, set_trashed, save_as_template, upload_file, embed_database |
| Databases | create_database, get_collection, update_schema, query_rows, create_rows, set_properties, set_view, delete_view |
| History and talk | revisions, comments, delete_comment, note |
| Sharing | set_sharing |
| Workspaces | workspace, propose_workspace_rules |
| Bulk import | import_url, get_import_status |
| Presence | working_on |
Orientation
list
One tool for “what is there of kind X?”. It replaced seven separate listing tools.
| Parameter | Type | Required |
|---|---|---|
kind | string | yes |
workspace_id | string | no |
under | string | no |
kind is one of pages, templates, tags, workspaces, files, users,
cover_presets.
What each returns:
- pages — an indented text tree of every page you may read, each line
- Title (id: …), with[database]appended to a database. Empty answer:No pages yet. - templates — JSON:
id,title,icon,kind,description,workspace_id. - tags —
{"tags":[{"tag":"…","count":n}]}, most frequent first, alphabetical on a tie. Call this before tagging so you reuse a tag instead of inventing a near-duplicate. - workspaces —
id,name,role,in_token_scope,has_rulesper workspace. If the connection was granted only some of your workspaces, the rest are counted, not named:not_granted: 3and a note. Names alone are information. - files —
url,name,ext,size,page_id,page_title, plus acount.undertakes a page id and limits the answer to that page and its sub-pages. - users —
id,name,emailfor everyone who shares a workspace with you, within the token’s scope. - cover_presets — the 18 gradients the interface’s own cover picker offers.
workspace_id is honoured by tags and files only. The other kinds
ignore it: pages, templates, workspaces and users always span everything the
token reaches, and cover presets are constants.
Three things about the file list are worth knowing, because it is an index and not a live scan of the disk:
- Called with neither
workspace_idnorunder, it covers your default workspace only, not every workspace you can reach. - It is filtered by workspace, so a file that belongs to no workspace never
appears. That is every file uploaded without a
page_id, and every workspace logo and account avatar — those hang off a workspace record or an account, not off a page. - A file whose block was deleted from its page stays in the list until the index is rebuilt, because nothing removes a single entry. The file is still on disk; the list is telling the truth about that, not about the page.
Errors: kind is required — use one of: …; unknown kind "x" — use one of: …;
workspace "…" not found.
search
Full-text search over titles, page content and the extracted text of indexed PDF attachments.
| Parameter | Type | Required |
|---|---|---|
| query | string | yes |
Search runs over passages, not whole pages, so a hit comes back as the paragraph that matched together with its heading path — not “something is somewhere in this 4000-word page”. Up to 20 results, one per line:
• Contract › Termination (id: 6f1c…)
…notice period of three months…
If passage search finds nothing, a whole-page search runs as a fallback. Empty
answer: No results. Errors: query is required.
See Search for what is indexed and how German words are folded.
get_page
Read one page as Markdown.
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
include_children | boolean | no, default false |
A document comes back as # Icon Title followed by the body. A database
comes back as a Markdown table of its rows: a Title column plus one column per
property, with select option ids resolved to their names.
include_children returns the whole sub-tree instead: each page as a heading
one level deeper than its parent (capped at six), separated by ---, with
sub-pages you may not read silently skipped.
The two forms are not the same read, and on a database the difference is
large. The table above is what a plain get_page produces. With
include_children the sub-tree export runs, which renders each page’s own
blocks — and a database page has none. You get an empty heading for the database
followed by one --- section per row. Ask for the table, or ask for the
sub-tree; you cannot have both in one call.
Errors: page "…" not found.
get_links
How pages hang together. With a page_id it answers backlinks; without one it
returns the whole graph.
| Parameter | Type | Required |
|---|---|---|
page_id | string | no |
workspace_id | string | no |
kinds | array of string | no |
include_nodes | boolean | no, default false |
Backlinks form (page_id given): {"page_id":"…","backlinks":[{id,title,icon}]}
— every page that links here, newest change first, filtered to what you may
read.
Graph form: edges as {from, to, from_title, to_title, kind}, where kind is
one of
| Kind | Meaning |
|---|---|
| link | a Markdown link in the body |
| child | a sub-page |
| row | a row of a database |
| embed | a database embedded in a page |
kinds keeps only the kinds you name. The answer also carries orphans —
pages with no connection at all — a top-level count (the number of edges), and
counts for nodes, edges and orphans. The full node list is opt-in via
include_nodes, because on a real instance it is thousands of entries and the
question people actually ask is answered by the orphans.
Both ends of an edge must be visible to you, or the edge is dropped: otherwise it would reveal that a private page exists.
Errors: unknown edge kind "x" — use link, child, row or embed;
page "…" not found; workspace "…" not found.
whoami
Who you are and what this connection may do. No parameters. Read-only.
Returns user_id, name, email, token_scope (“read” or “write”),
can_write, workspace_scope (either the list of workspace ids or
“all workspaces you are a member of”), a not_available_via_mcp list, a note,
and a before_you_start reminder to check in with working_on.
The note names the two boundaries agents most often walk into: list with
kind=“users” shows only the people you share a workspace with, and account
administration needs a signed-in browser session.
Call this first when a write fails. It separates “wrong id” from “not allowed” in one call.
get_workspace
| Parameter | Type | Required |
|---|---|---|
workspace_id | string | no — omit for your default workspace |
Returns id, name, my_role, members (each id, name, email, role
— these ids are what a person property wants), page_count, database_count,
has_rules and has_pending_rules_proposal.
After that block come the workspace rules, if there are any, outside the untrusted fence and framed to be followed. If the workspace has none and you are an admin there, the answer says so and suggests drafting some. If a proposal is already pending, it says that instead, so a second one does not get piled on top.
Errors: workspace "…" not found; or, when the workspace is yours but outside
this connection’s grant, workspace "…" is outside what this connection was granted — ask for it to be added, or name one it can reach. The second wording
is deliberate: “not found” for something you own sends an agent hunting for a
typo.
get_permissions
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
Returns page_id, workspace_id, my_role, can_read, can_write,
can_delete, in_trash and read_only_reason — the last being
this API token is read-only, you are a viewer in this workspace, or empty.
Cheaper than attempting a write and failing. Errors: page "…" not found,
which is also what you get for a page you may not read.
Pages
create_page
| Parameter | Type | Required |
|---|---|---|
title | string | yes |
template_id | string | no |
parent_id | string | no |
workspace_id | string | no |
markdown | string | no |
icon | string | no |
properties | object | no |
cover | string | no |
description | string | no |
tags | array of string | no |
Three things decide where the page lands. parent_id puts it under that page —
and if that page is a database, the new page is a row in it. With no
parent, workspace_id decides; with neither, it lands in your default
workspace, which may not be the one you meant.
icon takes an emoji, lucide:Name, mdi:Name, or an image URL. cover takes
only two forms: gradient:linear-gradient(120deg,#a8edea,#5b86e5) or an
uploaded path like /files/abc123.jpg. An external image URL is refused —
every viewer of the page would otherwise fetch from that host, which is a beacon
planted in somebody else’s document.
A Markdown link whose target is a page of this instance becomes a real page
link — it shows up in get_links and in the graph. Two forms count: the bare
path /p/<32-hex-id>, and an absolute URL ending in it,
https://salt.example.com/p/<32-hex-id>. Nothing else does. In particular a
public share link is not a page link: the address set_sharing hands back
is /public/<token>, and pasted as a Markdown target it stays an ordinary link
that navigates and leaves the page an island. Link to the page id, not to the
share.
tags are normalised the way the interface normalises them: a leading # is
dropped, spaces become hyphens, duplicates removed.
With template_id nothing else applies but title. The call goes straight
to instantiating the template and every other argument is ignored.
Returns Created page "Title" with id <id> (path: /p/<id>), or
Created page <id> from template <id>.
Errors: title is required; parent page "…" not found; template "…" not found; page "…" is not a template; the cover message above; you are a viewer in that workspace and cannot create pages there. A workspace-scoped token that
cannot enter your default workspace gets its own message when it creates with no
parent and no workspace_id: this token cannot create top-level pages in the default workspace; pass workspace_id … or a parent_id inside an allowed workspace — that one is worth reading rather than retrying, because retrying
the same call cannot succeed. If the page is created but its metadata or
properties then fail, the error names the new id so nothing is lost.
update_page
Metadata and place, in one tool. Only the fields you pass change.
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
title | string | no |
icon | string | no |
cover | string | no |
description | string | no |
visibility | string | no — workspace or private |
tags | array of string | no |
parent_id | string | no — "" moves to the top level |
workspace_id | string | no |
favorite | boolean | no |
The move happens first, then the favourite, then the metadata — so a failed move
does not leave a half-renamed page behind. workspace_id takes precedence over
parent_id: it moves the page and its whole sub-tree into that workspace,
where it lands at the top level, its previous parent staying behind.
tags replaces the whole list. favorite pins the page in the sidebar for
you alone.
Errors: nothing to update: pass at least one of title, icon, cover, description, visibility, tags, parent_id, workspace_id or favorite;
a page cannot be its own parent; cannot move a page into its own subtree;
a page can only be re-parented within its own workspace;
visibility must be "workspace" or "private".
write_content
Write Markdown into a page’s body.
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
markdown | string | yes |
mode | string | no — append (default), prepend, replace |
append is the default because it is the only one of the three that cannot
destroy anything.
All three leave a revision behind, but not the same one, and the difference is the whole point of the history:
prependandreplacesave the page before overwriting it. That older state is whatrevisionsgives you back, so both are undoable.appendsaves a revision after it has written. It records the new state, not the old one, so it is a marker in the history and not an undo point. It needs none: an append takes nothing away.
Snapshots are throttled to one per page every two minutes, so a burst of writes
leaves one revision rather than ten — and two replace calls a minute apart
leave only the state from before the first.
All three write past the realtime editor and then reset the live document. Anyone with the page open at that moment loses unsaved edits. That is why append is the recommendation, not politeness.
Returns Appended content to page <id>, Prepended N block(s) to page <id> or
Replaced content of page <id>.
Errors: unknown mode "x" — use append (the default), prepend or replace;
markdown is empty (prepend); page "…" not found.
duplicate_page
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
A deep copy of the page and its sub-tree, placed next to the original.
It copies only the parts you may read. A private sub-page belonging to somebody else is left out, and everything below it goes with it. That is not a rounding error to work around: without the rule, copying a tree turned “no access” into “mine”, because the copy takes your name as its owner while the private flag stays on.
Returns Duplicated page → new id <id>.
set_trashed
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
trashed | boolean | yes |
One tool rather than two, because neither direction destroys anything — the page keeps existing either way. See Trash and recovery.
The two directions are not mirror images. Trashing takes the whole sub-tree
and reports how many went with it:
Moved page <id> (and N sub-pages) to trash. Restoring brings back only the
page you named — its sub-pages stay in the trash, and if its parent is still
trashed the restored page has no visible place in the tree. Restore the parent
first, or restore the page in the browser, where the whole batch comes back.
Errors: trashed is required (true to move to the trash, false to restore);
page "…" not found; page "…" is not in the trash.
save_as_template
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
Saves the page and its sub-tree as a template. This is a snapshot: the copy
becomes the template, the page stays an ordinary page, and later edits to the
page do not change what the template offers. Returns Saved page <id> as template <id> — the page itself is unchanged. See Templates.
It needs write permission on the page, not just read — a template is a new
object made from somebody’s content. Like duplicate_page, it copies only the
parts of the sub-tree you may read.
Errors: page_id is required; page "…" not found, which is also the answer
for a page you may read but not write.
upload_file
| Parameter | Type | Required |
|---|---|---|
file_name | string | yes |
data_base64 | string | yes |
page_id | string | no |
With a page_id the file is appended to that page as a block — an image block
for .png, .jpg, .jpeg, .gif and .webp, a file block for everything
else. A .pdf also gets its text extracted and indexed, so its contents become
findable by search.
Without a page_id the upload still succeeds, and that is the case to be
careful with. The bytes are written and you get a /files/… address back, but
the file belongs to no page and therefore to no workspace: nothing links to it,
list with kind=files will never show it, and a PDF’s text is not extracted,
so search cannot find it either. It is reachable only by the address in the
answer. Pass the page you mean it to live on unless you have a reason not to.
The size is judged from the encoded length before anything is decoded, so an
oversized upload is refused without a second copy being made of it. Returns
Uploaded <name> → /files/<stored-name>.
Errors: file is N MB — the limit is M MB; upload it through the browser (/api/upload) or raise max_upload_mb in the settings; data_base64 is not valid base64. For anything large, /api/upload is the better road — see
Files.
embed_database
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes — the document |
database_id | string | yes |
Puts an existing database into a document, at the end of its content. Only a reference is stored: the database stays one object in one place, and the same database can appear in several documents. Use this instead of creating a separate intro page beside a database — a database page cannot have a body of its own.
Errors: database "…" not found; page "…" is a document, not a database — embed only works with databases.
Databases
Everything below operates on a collection in the interface’s words. Read Collections, Properties and Views for what the pieces mean.
create_database
| Parameter | Type | Required |
|---|---|---|
title | string | yes |
parent_id | string | no |
schema | array | no |
properties | array | no — an alias for schema |
workspace_id | string | no |
Both schema and properties are accepted for the same thing, because half the
callers reached for the other name and got the default schema silently.
Each entry is {id?, name, type, …}. Types:
text, number, select, multiselect, date, checkbox, checklist,
url, person, relation, backrelation, rollup, formula.
Options may be written the convenient way — ["To do","Done"] — or as objects
with a colour: [{"name":"Done","color":"#2f9e44"}]. Plain strings are
converted for you; stored raw they used to crash the page on opening. An id you
do not give is derived from the name, so a property called “Due date” gets the
id due-date.
Omit the schema entirely and you get one property — a Status select with the
options To do, In progress and Done — and two views: a Board grouped by
Status, and a Table. Two views matter for what you can do next: delete_view
refuses the last one, so with the default you can delete one of them and not
the other.
Returns Created database "T" with id <id>. Errors: title is required;
schema is not valid JSON; schema must be an array of property definitions…;
unknown property type "x" on "Name" — use one of: …; each property needs a name; options on "Name" must be a list.
get_collection
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
Returns {page_id, schema, views} — the full property schema and every view
with its id. Call this before any other database tool: property ids, select
option ids and view ids all come from here. Errors: page "…" is not a database.
update_schema
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
properties | array | no |
remove_properties | array of string | no |
Merges. Properties you do not mention stay untouched. Pass an existing id
to change that property field by field; omit the id to add a new one.
Removing a property keeps the values in the rows, so re-adding the property brings them back. That is the cure for an accidental removal.
Beyond name and type, a property entry may carry options, formula,
numberDisplay, numberMax, relationCollection, backrelationCollection,
backrelationProp, rollupRelation, rollupTarget, rollupAgg,
rollupWhereProp, rollupWhereOp, rollupWhereValue and rollupWhereValues.
Relations and rollups explains what these do; three
points matter here:
- A backrelation stores nothing. It asks which rows point here, so it needs
both
backrelationCollection(the database over there) andbackrelationProp(its relation property). Without both it is refused — an empty backrelation reads exactly like “nothing points here”, which is worse than an error. - A rollup may carry a condition.
rollupAggissum,count,avg,min,maxorpercent. For several values userollupWhereValuesrather thanrollupWhereValue: “open” usually means is not done and is not discarded, and one comparison cannot say that. - A checklist value is a list of sub-tasks,
[{"text":"…","done":false}], and shows its own progress. Do not add a second percentage column beside it.
Returns Schema updated: added …; changed …; removed … (row values kept, re-adding the property brings them back). Errors: nothing to do: pass properties to add/change or remove_properties; unknown property type "x";
each property needs at least a name.
query_rows
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
filter | array | no |
sort | string | no |
limit | integer | no |
offset | integer | no |
Each filter is {property, op, value}, all ANDed together. property is a
property id from get_collection — the row title is not a property, so
filter titles with search instead. Operators:
| Operator | Meaning |
|---|---|
is | equal — the default when a value was given |
is_not | not equal; a missing value counts as not equal |
contains | substring, also inside list values |
gt, lt | greater / less than; numeric only when the value you pass parses as a number, otherwise a text comparison |
is_empty | unset, empty string, or an empty list |
is_not_empty | anything else — and the default when op and value are both left out |
Two of those defaults deserve a second look. Omitting op does not always mean
is: with an empty value it means is_not_empty, so
{"property":"owner"} asks “has an owner”, not “owner is blank”. And gt/lt
decide numeric or text from the value you pass, never from what is stored:
{"property":"amount","op":"gt","value":"abc"} compares text on a number
column, and {"property":"code","op":"gt","value":"5"} reads a text column as
numbers.
value is always a string, even for a number. A select value written as its
name is matched to its option id for you, so you can search with the same
word you wrote with.
sort is propertyId:asc or propertyId:desc — the same spelling set_view
uses. limit defaults to 50; anything above 500 or below 1 falls back to 50
rather than being clamped to the maximum.
Two mistakes fail soft here, which is worth knowing because neither produces
an error. A filter whose property id contains anything unusual is dropped and
the rest of the query runs — so the answer is wider than you asked for, not
empty. And a sort naming a property that is not in the schema falls back to
the rows’ own order. If a result looks unfiltered or unsorted, suspect the
spelling before the data, and check the ids with get_collection.
Returns {rows, total, offset, limit}. Each row carries id, title, icon,
cover, position, props and tags, with rollups, formulas and
backrelations already computed. total is the count after filtering, so
paging is honest. Other people’s private rows are excluded from both the rows
and the total — unless you are an admin of the workspace, who sees them all,
the same rule the rest of the product applies.
Errors: page "…" is not a database.
create_rows
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
rows | array | yes |
Up to 200 rows in one call, each {title, icon?, properties?}. For 40 rows
this is one call rather than forty, each of which could fail and leave half a
state behind.
page_id is checked for existing, not for being a database. Point it at an
ordinary document and the call reports rows created — they are created, as
ordinary sub-pages of that document, and they appear in no view because there is
no schema and no view to put them in. The error text says database "…" not found, which only ever means the id is wrong or out of reach. Confirm the
target with get_collection first if the id came from somewhere other than a
listing.
Returns {"created":n,"ids":[…]}. Errors: rows must be a list of {title, icon?, properties?}; rows is empty; at most 200 rows per call (got N) — split into batches; every row needs a title (row N is empty) — nothing was created; database "…" not found. If a row fails partway through, the error
says how many were created before it — those stay.
set_properties
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
properties | object | yes |
A map of property id to value. Field-level merge: only the keys you send
change, and a value of null clears one.
Two conveniences, both of them from watching agents write perfectly reasonable JSON that then went quiet:
- A select value written as its name becomes the option id. Written as
{"status":"Planned"}it is stored asplanned. Unresolved, such values did land in the row but no board column and no filter ever found them again. - A list-shaped property — relation, multi-select — written as a single
value becomes a one-element list.
{"system":"abc"}used to store literally like that: the row still grouped and still filtered, because both compare loosely, while every rollup and backrelation passed straight over it and the chip stayed blank on the card.
An open board updates live, so a person watching sees the row move.
Returns Set N property/properties on row <id>. Errors: properties is required; properties must be a JSON object; page "…" not found; page "…" is in the trash.
There is also an undeclared batch form: an array under the key “updates”, each
entry {page_id, properties}, up to 200. Permissions for all of them are
checked before the first change, so the call cannot leave a half-updated
database. Because the parameter appears in no schema — and page_id and
properties are both marked required — a strict client will not let you send
it.
set_view
Create a view, or change one.
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
view_id | string | no — omit to create |
name | string | no |
type | string | creating only |
group_by | string | board |
date_prop | string | calendar, timeline |
end_date_prop | string | timeline |
filters | array | no |
sort | string | no |
hidden | array of string | no |
Types: table, board, gallery, calendar, timeline, list, form.
Without view_id it creates and type is required. With one it merges —
what you do not pass stays as it is. A view’s type cannot be changed: delete
it and create the new one.
A board needs group_by, a calendar and a timeline need date_prop, and both
are checked up front: a board without a grouping renders empty and sends you
hunting for the mistake in the data rather than in the call.
What is checked is that the property exists in the schema, not what type it is. A board grouped by a date or a checkbox is created without complaint — and then shows “This board needs a Select property to group by. Open ⚙ Properties to add one.” instead of columns. The types that produce columns are select, multi-select and relation; those are also the only ones the interface’s own group-by picker offers.
Clearing works through empty values: "" for sort, [] for filters and
hidden. group_by and date_prop cannot be cleared on the view types that
need them — passing "" removes the setting and the view is then refused for
being incomplete, so a board with group_by: "" comes back as a board needs group_by, and a calendar or timeline with date_prop: "" likewise. To be rid
of the grouping you delete the view.
Filters take the same operators query_rows does. A board people actually work
in usually needs one — without a “status is not done” filter the finished column
grows forever and pushes the work aside.
An unnamed view is named after its type, capitalised.
Returns Created <type> view "Name" (id …) or Updated view "Name" (id …).
Errors: unknown view type "x" — use table, board, gallery, calendar, timeline, list or form; a view's type cannot be changed — delete it and create the new one; a board needs group_by (the property to make columns from);
a calendar needs date_prop (a date property id); "x" is not a property of this database; filter N: unknown op "x" — use is, is_not, contains, gt, lt, is_empty or is_not_empty; view "…" not found — call get_collection for the ids.
delete_view
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
view_id | string | yes |
The last remaining view cannot be deleted — a database needs something to show.
Deleting is its own tool rather than an action word on set_view, so it is a
deliberate choice and not a value an agent lands on by mistake.
Errors: cannot delete the last view — a database needs at least one;
view "…" not found — call get_collection for the ids.
History, comments and notes
revisions
A page’s history. See History and audit.
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
action | string | no — list (default), get, restore |
revision_id | string | for get and restore |
limit | integer | list only |
list returns {page_id, revisions:[{id, created_at, author, title, content_bytes, by}]}, newest first. Limit defaults to 20; anything above 100 or
below 1 falls back to 20. The newest 50 revisions of a page are kept; older ones
are dropped.
by is the interesting column, and the one most easily misread. It is
human, agent, or unknown. unknown does not mean old: it means the
revision could not be matched to an entry in the audit trail. That covers
revisions written before the trail existed, and it also covers every revision
an append leaves behind, because that path records no author to match on. A
history full of unknown on a page an agent has been appending to is the normal
picture, not a sign of missing data.
get returns one older state as {page_id, revision_id, created_at, author, title, markdown}.
restore puts the page back to that state — and saves the current state as a new revision first, so restoring is itself reversible. That is why it lives here rather than behind its own tool.
Reading counts as a read and restoring as a write, so a read-only token may list and get but not restore.
Errors: unknown action "x" — use list (the default), get or restore;
revision_id is required for action=get; revision "…" not found on page ….
comments
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
action | string | no — list (default), add, resolve, reopen |
body | string | add only |
block_id | string | add only |
comment_id | string | resolve and reopen |
list returns the page’s comments as JSON. add posts one; give block_id to
attach it to a single block instead of the page. resolve ticks one off,
reopen un-ticks it.
There is also an undeclared resolved boolean, and where it appears it wins:
action: "resolve" together with resolved: false reopens the comment. Two
ways to say the same thing, and only one of them is in the schema — send the
action alone unless you have a reason.
Deleting is deliberately not an action here — see below.
Returns Added comment <id>, Resolved comment <id>, Reopened comment <id>.
Errors: unknown action "x" — use list (the default), add, resolve or reopen; deleting has its own tool; body is required for action=add;
comment_id is required for action=resolve; comment "…" not found.
The permission check on resolve and reopen runs against the comment’s page, resolved from the comment id — a guessed id cannot write into somebody else’s workspace.
delete_comment
| Parameter | Type | Required |
|---|---|---|
comment_id | string | yes |
Deletes a comment permanently. Its own tool on purpose: destroying should be a
choice of tool, not an enum value. Errors: comment "…" not found, which is
also the answer for a comment on a page you may not write to.
note
Drop one line on a page’s raw trail while you work. One argument that matters, no title, no place to choose. See Comments and notes.
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
text | string | yes |
agent | string | no |
label | string | no |
Use it for what a write-up loses: the approach you tried and dropped, the thing that surprised you, the number you looked up, why you did not take the obvious road. Write it as it happens — that is the whole value.
A note can never be edited or removed, by you or anyone, which is exactly what makes it worth reading later: you wrote it before you knew how it would end. Correct a wrong one by adding another that says so. A person can discard a page’s whole trail deliberately, and that discarding is itself logged.
Everyone who may read the page sees the trail in the browser. No MCP tool
hands it back: there is no kind of list for notes and nothing returns them, so
an agent can write to the trail and cannot read what it or anyone else wrote
there. Over HTTP it is GET /api/pages/{id}/notes, which needs read permission
on the page — see API.
This tool needs write permission. Text over 2000 characters is truncated without complaint.
Returns Noted, N on that page now. Nobody can edit or remove a single one, including you — correct a wrong note by adding another. Errors:
a note needs text; page "…" not found.
This is not working_on: that says you are here now, this leaves something
behind.
Sharing
set_sharing
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
public | boolean | yes |
expires_in_days | integer | no |
password | string | no |
public: true mints a link anyone can open without signing in. Only do it
when the user asked. Returns {page_id, url, expires, note}, the url being
https://salt.example.com/public/<token> on your own domain. That address is
for a human to open — it is not the form of link that turns into a page link
when you write it into Markdown (see create_page).
There is one live share per page: sharing again replaces the previous link,
which is why a link somebody believes revoked cannot quietly keep working.
public: false revokes it and answers Revoked the public link for page <id>,
or Page <id> was not shared publicly if there was none.
A public form on the same page is left alone, in both directions. Minting and revoking touch the reading share only, so revoking a page’s public link does not close a form that is collecting submissions. Forms are closed where they are made — see Forms.
Errors: public is required (true to create a public link, false to revoke it).
More on what a visitor sees in Sharing.
Workspaces
workspace
Create a workspace, or change one.
| Parameter | Type | Required |
|---|---|---|
workspace_id | string | no — omit to create |
from_workspace | string | creating only |
name | string | required when creating |
icon | string | changing only |
Creating makes you the workspace’s admin. Changing needs you to be one
already, and accepts a new name (80 characters at most) or an icon — the
icon field is for an emoji and is cut to 8 characters, so nobody smuggles text
into the sidebar.
from_workspace starts from an existing workspace’s structure instead of
from nothing: its rules, its databases, their property schemas with the option
ids, and their views. No rows and no documents — a blueprint carrying
somebody’s tasks is not a blueprint. There is no separate template object on
purpose: the workspace you point at is the blueprint, so it cannot drift out of
step with how you actually work. Use update_page with workspace_id afterwards
to move existing pages in.
Creating from nothing returns Created workspace "N" with id … — you are its admin. followed by two more sentences: a reminder to move existing pages in,
and a note that the workspace has no rules yet and that conventions can be
drafted with the user and submitted via propose_workspace_rules. The
reminder still names a tool that no longer exists (move_page, folded into
update_page) — read it as update_page with workspace_id. Creating from a
blueprint returns Created workspace "N" with id … from the structure of …: N database(s) with their schemas and views, no rows. Changing returns
Updated workspace <id>.
Errors: name is required; name is too long; creating workspaces is disabled on this instance; this connection is limited to particular workspaces, so it cannot create new ones — it would not be able to open them; pass workspace_id to set an icon — a new workspace is created with a name only; only a workspace admin can change "…"; nothing to change: pass name or icon; workspace "…" has no databases to copy — nothing to use as a blueprint.
propose_workspace_rules
Submit a draft of workspace rules — working conventions about naming, structure, where content goes, what to leave alone.
| Parameter | Type | Required |
|---|---|---|
rules | string | yes |
workspace_id | string | no — omit for your default workspace |
Workspace admins only. A token whose account is not an admin there is refused, so there is no point raising the subject with a non-admin user at all.
The draft never becomes active by itself. A workspace admin reviews and applies
it in the browser, under the workspace menu, and that review cannot be skipped
over MCP. That is what makes it safe for get_workspace to hand rules to an
agent as instructions to follow: nothing holding an API token can rewrite them.
Markdown, 16000 characters at most. One slot per workspace — a new draft replaces the pending one. An empty string withdraws your own pending draft; somebody else’s can only be dismissed in the browser.
Returns {"ok":true,"workspace_id":"…","note":"Proposed — NOT active yet…"},
or Withdrew your pending rules proposal., or There is no pending proposal to withdraw.
Errors: this API token is read-only; proposing rules requires a write token;
workspace "…" not found; workspace rules are managed by workspace admins; your token's account is not one here; workspace rules are limited to 16000 characters; the pending proposal is not yours to withdraw — an admin can dismiss it in the browser.
Bulk import
import_url
Import records from a JSON URL. Salt fetches and writes them itself, so none of the content passes through the agent.
| Parameter | Type | Required |
|---|---|---|
url | string | yes |
title | string | yes |
headers | object | no |
items | string | no |
markdown | string | no |
properties | object | no |
resolve | object | no |
database_id | string | no |
parent_id | string | no |
workspace_id | string | no |
limit | number | no |
Use this rather than looping create_page or create_rows whenever there are
more than roughly 20 records: a large source exhausts an agent’s context long
before the import finishes. This is the reversal — the agent names the source and
the mapping, a few hundred characters, and the size of the source stops
mattering.
items is the path to the array of records (cards, data.results); omit it
if the response is the array. title names the field each record’s title comes
from and is required. markdown names a field used as the page body.
properties maps a database property name to a source path, including
labels[].name for a list. resolve turns foreign ids into readable names using
another array in the same answer:
{"idList": {"from":"lists","match":"id","to":"name"}}.
The target is one of three: database_id imports rows, parent_id imports
pages under a page, workspace_id imports top-level pages. Missing select
options are created for you, with a colour, so a board is not a row of
colourless columns.
limit imports only the first N records — a trial run before the real thing.
Two things happen before the job starts, so a mistake fails immediately rather than halfway — and they happen in this order:
- The target is resolved and write permission on it is checked, along with every property name you mapped.
- Only then is the source fetched and the mapping applied.
The order shows in what you get back. A wrong target fails with a permission or property error and the source is never fetched at all, so the answer says nothing about whether the URL was reachable. Fix the target first, then the mapping.
Only public hosts can be fetched: loopback, private ranges and link-local
addresses — where cloud metadata services live — are refused. A self-hosted
source needs the operator to start the server with
SALT_IMPORT_ALLOW_PRIVATE=1; an agent cannot open that door.
Returns immediately with {job_id, status, total, target, note, next}. Ceilings:
64 MB of fetched source, 20000 records.
Errors: url is required; title is required — name the field each record's title comes from; items path "…" does not point at a list; the source contains no records at "…"; the source has N records, more than the limit of 20000; the source is not valid JSON; the database has no property "x" — it has: …; database "…" not found.
get_import_status
| Parameter | Type | Required |
|---|---|---|
job_id | string | yes |
Poll every few seconds until the status is done. The answer carries the whole job: job_id, status (running, done or failed), total, created, failed, up to ten errors, target, started_at and finished_at.
Job status lives in memory, not in the database, and only the last 20 jobs are kept. A restart loses the status — never the work: pages already created are saved. Somebody else’s job reads as not found.
Errors: import job "…" not found — job status is kept in memory, so it is lost if the server restarts (pages already created are not).
Presence
working_on
Say that you are working on a page, so a person watching sees it live — and say when you are done.
| Parameter | Type | Required |
|---|---|---|
page_id | string | yes |
agent | string | no |
label | string | no |
note | string | no |
expected_minutes | integer | no |
done | boolean | no |
Check in before you start on something that takes a while, and call it again
with done: true when you finish.
agent is one of claude, chatgpt, codex, cursor, openclaw, hermes,
gemini, generic — those are the ones with a logo of their own. An unknown
name is not refused; it becomes generic, and what you called yourself survives
in label. The name is a claim, so the verified account travels beside it:
a badge reads “Claude, via Ada Lovelace”.
The note is the valuable part. “tidying the file index” answers the question somebody actually has; “working” does not.
Nothing expires on you. An agent has no clock and cannot wake itself to say “still here”, so a ten-minute lease would erase a three-hour job halfway through. The entry stays until you check out, the interface fades it after ten minutes of silence using two timestamps (“here for 2h 14m, last seen 47 min ago”), and a session silent for 12 hours is swept as crashed. Every other call you make naming that page counts as a sign of life, including one that is refused — an agent whose write bounced is still alive.
Checking in again keeps the original start time, so “still on it, now with a different note” does not reset the clock.
Checking out leaves your last note behind as an entry on the page’s raw
trail. Pass a note on the closing call and that one is used: “done, and here
is how it went” is the most useful last line there is.
Returns Checked in on page <id>. You stay listed until you check out (done: true) — nothing expires on you mid-task. or Checked out of page <id>. Your last note stays on the page as a trail entry. or Nothing to check out of — you were not marked as working on that page.
Errors: page_id is required; page "…" not found.
Reading permission is enough for the whole tool, including the trail entry
the check-out leaves behind. That is the one place where a note reaches a page
you cannot write to. The separate note tool is the one that requires write
permission.
Where things are written down
Every write through MCP lands in the audit log as an agent action, with the
account, the tool name, the page and the result. working_on writes its own
entries instead — one on check-in, one on check-out, with the note as the
detail. note writes none: its trail entry already is the record, and copying
it into the audit log would spread it to a second place read by a different set
of people and buy nothing.
Related pages
- Agents — connecting one, and what the surface is for
- Agent access — tokens, OAuth, and what a workspace lets in
- Skill — the instruction file an instance generates for itself
- API — the REST surface the interface itself uses
- Workspaces — rules, roles, blueprints