@levitating/tensor-mcp
v5.1.0
Published
Tensor MCP — Model Context Protocol server for the Tensor wealth management API. Exposes the Tensor API as tools for Claude, Cursor, Codex, and any MCP-aware client.
Maintainers
Readme
Tensor MCP
Model Context Protocol server that exposes the Tensor API as tools for AI assistants. Hosted MCP prefers OAuth discovery and browser consent; it also accepts a scoped Clerk API key where applicable. Both credentials obey the same deny-by-default REST scopes and allowlist — an MCP client can never reach anything its credential could not reach over REST.
Tensor is a private family office workbench: a ledger of entities, accounts, assets, positions and transactions, plus the work around it — todos with canonical tags, an idea capture inbox, encrypted files. Since 2.1.0 the work surfaces are part of this API too, each under its own scopes: what you get is the ledger, the reference catalogs, the dry-run/apply pipeline, price/quote lookups, scope-aware site-wide search, files (upload, browse, folders, download URLs, export manifest), todos, ideas, and Web — bookmarks with their folder tree, and tab groups synced from a browser. The boundary that remains is deliberate — no unrecoverable destruction, and no self-widening: every Trash purge (todos, ideas, files, bookmarks, tab groups), comment deletion, decryption-device enrollment, file access logs, generic cross-surface /api/links* and /api/tags*, FX, and loan terms stay session-only. Resource-specific File/Todo/Idea link tools and search_records are public only through their underlying scoped surfaces, and Tensor's smoke tests assert those boundaries in both directions. The reasoning lives in docs/contract/MCP.md in the Tensor repo.
Published as @levitating/tensor-mcp@^5.1.0. The complete inventory is the tables below — every tool, nothing needing a clone of the Tensor repo. CI derives and checks every listed name against mcp-server/tools.ts, in both directions.
See the full docs at tensor.levitating.org/docs/mcp. The contract behind these tools — scope mapping, access boundaries, and the version policy — is docs/contract/MCP.md in the Tensor repo.
5.1.0 — Web: bookmarks and tab groups
Seventeen new tools, no breaking change. The Web surface — bookmarks filed into a folder tree, and tab groups parked from the browser extension — has had tables, REST routes, scopes and an OpenAPI contract since it shipped, and had none of them here.
Bookmarks: list_bookmarks, get_bookmark, create_bookmark,
update_bookmark, delete_bookmark, restore_bookmark, and the folder tree
with list_bookmark_folders, create_bookmark_folder,
update_bookmark_folder, delete_bookmark_folder.
Tab groups: list_tab_groups, get_tab_group, create_tab_group,
update_tab_group, delete_tab, delete_tab_group, restore_tab_group.
Two new scopes, and they are separate on purpose: tensor:bookmarks:read /
:write and tensor:tabs:read / :write. A synced tab group is in effect a
partial browsing history, so a key issued to read the pages you deliberately
filed must not also read every page you merely parked. Existing keys carry
neither and reach neither — attach them when you create the key.
Both deletes are soft and both restores are here. Neither permanent purge is, on the rule every work surface shares: a key may fill a Trash and only a person may empty one.
5.0.0 — Todo labels are Todo tags
Breaking tool-name change. Todos and Ideas now use Tensor's canonical tag
catalog and assignments. Todo reads return tags and tagIds, and Todo tag
writes use the same records that the web app, REST API, filters and settings
use.
Rename calls by replacing set_todo_labels with set_todo_tags,
list_todo_labels with list_todo_tags, create_todo_label with
create_todo_tag, update_todo_label with update_todo_tag, and
delete_todo_label with delete_todo_tag.
There are no MCP aliases for the old tool names. Update any prompt, script or
saved workflow that calls a tool by name. Hosted HTTP clients only need to
refresh or reconnect so they fetch the latest tool catalog. Local stdio clients
must update to @levitating/tensor-mcp@^5.0.0 and restart the client.
The underlying REST label routes remain deprecated compatibility adapters, but
new integrations should use PUT /api/todos/:id/tags with { "tagIds": [] }.
MCP tools never write tag_catalog or tag_assignments directly: the REST
handlers validate scope and target type, then perform the canonical catalog and
assignment writes transactionally.
2.0.0 — breaking change
Tensor's Documents feature was renamed to Files (2026-08), all the way down: tables, routes, scope and tool names. For an MCP client that means:
| What | Was | Now |
| ---- | --- | --- |
| Tool name | list_documents | list_files |
| Scope on the key | tensor:documents:read | tensor:files:read |
| Endpoint behind it | GET /api/documents/manifest | GET /api/files/manifest |
There is no alias and no deprecation window — the old names are gone. Existing API keys carrying tensor:documents:read must be re-issued with tensor:files:read, and any prompt, script or saved workflow that calls list_documents by name has to be updated. Everything else in the tool set is unchanged.
4.4.0 — browse_files can say what order it wants
Additive, one optional input. browse_files takes sort, one of
modified_desc / modified_asc / added_desc / added_asc / name_asc /
name_desc / size_desc / size_asc. The default is added_desc — newest
first, exactly what every previous release returned. Nothing is renamed, nothing
is removed, no scope changes, and a call that omits it behaves as before.
It closes the last gap where Tensor's Files page could do something an agent
could not. The four columns are the page's own — modified is updated_at,
added is created_at, name is the display title and size is the
plaintext length, so an encrypted file sorts by what it is rather than by
its envelope's overhead. One name carries both the column and the direction
because the server looks the value up in a closed map of ORDER BY fragments;
a direction sent separately would only be composed back into that key.
It is a server-side order, and that is the point. The listing is capped
(default 500), so re-sorting a page in the client orders the window rather than
the library — "the ten biggest files" computed that way is the ten biggest of
the five hundred most recent. Every order also ends in the file id, so paging
with limit/offset cannot show one row twice and skip another.
Two things worth knowing before you trust an ordering: names sort by the
database's collation rather than a natural sort, so Q10 comes before Q9; and
an unrecognised value is ignored rather than refused — ordering is not a filter,
so a bad one costs the default order and never an error.
4.3.0 — list_ideas can say what order it wants
Additive, one optional input. list_ideas takes sort: newest (the
default, and what every previous release did), oldest, updated (recently
edited first) or title (A-Z). Nothing is renamed, nothing is removed, no scope
changes, and a call that omits it behaves exactly as before.
It closes a gap rather than adding a capability: Tensor's own Ideas stream has
offered these four orders for as long as the arrangement work has been in, and
GET /api/ideas?sort= has parsed them the whole time — the tool simply had no
way to ask, so anything an agent wanted in a different order had to be paged out
in full and re-sorted locally, which for a capped listing quietly answers the
wrong question. list_todos has had its own sort since it shipped; this is
that shape one noun over.
There is deliberately no manual. A todo carries a fractional rank key and
can be hand-placed; an idea has no such column, and an inbox is not something
anyone arranges by hand. An unrecognised value is ignored rather than refused —
ordering is not a filter, so a bad one costs you the default order and never an
error.
4.2.0 — an idea's body is a document
Additive, and the wire did not change shape. An idea's body used to be a plain
TEXT column; it is a TipTap document now, the same as a memo's body and the
same as an idea's own comments, so an idea can hold an inline image.
body still goes in as plain text and still comes back as plain text — Tensor
wraps what you send into a document (one paragraph per line) and derives body
back out of it for search and previews. Reads gain bodyJson, the document
itself; it is null on a capture written before this release that has not been
edited since, which means "body is the whole of it". Nothing is renamed,
nothing is removed and no scope changes, so no installed client and no issued
key is affected.
An agent cannot put an image INSIDE the prose, and that is deliberate. These
tools take body as text and there is no bodyJson input: publishing the
editor's node shape as a tool input would freeze an internal name into a
permanent major-bump obligation, and hand-built ProseMirror JSON is the most
likely thing to arrive unopenable. The capability you actually want — "give this
idea a picture" — is the gallery, and it is the same file row an inline
image points at: create_idea_upload_url → PUT the bytes → confirm the
storageKey in media. A URL or a markdown image typed into body is stored
as the literal text you typed.
Two things to know before you write one. Sending body REPLACES the whole
document, inline images included — that is the honest reading of "set the body
to this text", and it is worth a read of the idea first if you did not write it.
The image is not destroyed: an inline image is a file row in the idea's
gallery, so it survives there and only stops appearing in the prose. And body
is now capped at 256000 characters, the ceiling todos, memos and comments have
always had; a longer body is refused at the edge rather than stored and then
failing to open.
4.1.0 — an idea's record links
Additive. Three new tools — list_idea_links, attach_idea_link,
detach_idea_link — plus a targetType/targetId filter on list_ideas.
Nothing is renamed, nothing is removed and no scope changes, so every installed
client keeps working and no key needs re-issuing.
What it closes is an asymmetry rather than a gap in Tensor: links is
undirected and a todo could already be linked to an idea through
attach_todo_link, so an agent could file work against an idea and had no way
to file an idea against anything. Tensor's own web app made those links through
a session-only route, which is why nothing was broken and why no key could do
it.
They carry the same two-scope rule as their todo and file counterparts — the
surface's own scope plus the link target's read scope, reads narrowing silently
and writes answering 403 — and the same one-door rule about attachments: an
idea's files are its gallery (get_idea → media), attach_idea_link will
not take file, and detach_idea_link refuses a gallery link id with a 409
instead of quietly removing a photo.
4.0.0 — Archive removed from Ideas
Ideas follows Todos. list_ideas loses archived and update_idea loses
the archived field; Delete is the only way to take a capture out of the
stream, it is soft, and the row lands in a Trash that empties itself 60 days
later. delete_idea and restore_idea are unchanged and already did exactly
this.
No tool was renamed and no scope changed, so no key needs re-issuing — but a
saved prompt or script that passes archived to either tool stops archiving,
and that is what makes it major. Both were REMOVED rather than accepted and
ignored: a read filter that quietly stops filtering only widens a result set,
while a write that quietly does nothing reports success for work it did not do.
Tensor's HTTP API keeps ?archived on GET /api/ideas as an inert parameter
for the same read-side reason, so a raw HTTP caller sees the whole stream rather
than an error. PATCH /api/ideas/:id refuses an archived field outright.
3.0.0 — Archive removed from Todos
archive_todo is gone; delete_todo replaces it. The rename carries a real change: the old tool archived a todo (a permanent resting place, no expiry), and the new one soft-deletes it to a Trash that empties itself 60 days after deletion. restore_todo still undoes it, and now takes withAncestors for the case where an ancestor is also in Trash. list_todos loses includeArchived / archivedOnly and gains trash.
No scope changed and no key needs re-issuing — tensor:todos:write covers both — but a saved prompt or script that calls archive_todo by name stops resolving, which is what makes this major. There is deliberately no purge tool and no empty-Trash tool: those are session-only in Tensor, and a tool whose every call returns 403 would be a promise this server cannot keep.
2.1.0 — todos, ideas, and full files access
Tensor opened its work surfaces to API keys (2026-08), and this server grew tools for all three: todos (with workflow states and canonical tags), the ideas capture inbox, and full file management alongside the existing export manifest. Additive only — every existing tool, name and scope is untouched, so installed clients keep working. The new tools need the new scopes (tensor:todos:read/write, tensor:ideas:read/write, tensor:files:write — see the table below) attached to your key when it is issued or re-issued.
Do you need this package at all?
Probably not. Tensor hosts an MCP endpoint that speaks OAuth, so a client that can open a browser needs a URL and nothing else — no install, no key to paste, no key to rotate:
https://your-instance.example.com/mcpPoint your client at it and connect. It reads the WWW-Authenticate challenge
on the first 401, discovers Tensor's authorization server, registers itself,
shows you a consent screen and holds its own token. Revoke it later from
Settings → Connected Apps. That is the path for Claude Desktop, Claude Code,
and any agent platform with a "Connect" button.
This npm package is the stdio transport, for clients that have no browser — CI jobs, scripts, an agent on a machine nobody is sitting at. A local process cannot complete an OAuth redirect, so it authenticates with an API key instead. That is the whole difference; the tool set is identical, because both transports register from the same file.
| | Hosted /mcp | This package (stdio) |
| --- | --- | --- |
| You hand out | a URL | an API key |
| Credential | OAuth token the client gets itself (an API key also works) | TENSOR_API_KEY |
| Good for | anything interactive | headless / CI |
| Tools | identical | identical |
Hosted client setup: Perplexity and Cursor
For Perplexity, add a custom remote MCP/connector with
https://tensor.levitating.org/mcp. Choose OAuth/Connect when the client
offers it and complete Tensor's browser consent. For Cursor, add a remote
MCP server in Settings → Tools & Integrations / MCP using the same URL, then
choose Connect and complete consent. These are hosted configurations: do not
put npx or TENSOR_API_KEY in them.
An API key is an intentional fallback for a hosted client that cannot complete OAuth: configure the scoped Tensor key as its Bearer/API-key credential. For a local stdio client, use the configuration below; stdio is API-key-only and does not participate in the hosted OAuth redirect flow.
Configuration
Set two environment variables:
| Variable | Description |
|----------|-------------|
| TENSOR_API_KEY | Your Tensor API key — shape ak_live_… (or ak_test_…). Create one at Developer → Access inside your Tensor instance and attach the scopes listed below. Shown once at creation. |
| TENSOR_BASE_URL | Tensor instance URL, e.g. https://tensor.levitating.org (default: http://localhost:5000) |
Running standalone
TENSOR_API_KEY=ak_live_xxxxxxxxxxxx \
TENSOR_BASE_URL=https://tensor.levitating.org \
npx -y @levitating/tensor-mcp@^5.0.0Claude Code integration
Add to your ~/.claude.json or project .claude.json:
{
"mcpServers": {
"tensor": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@levitating/tensor-mcp@^5.0.0"],
"env": {
"TENSOR_API_KEY": "ak_live_your_key_here",
"TENSOR_BASE_URL": "https://your-instance.example.com"
}
}
}
}Claude Desktop integration
Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"tensor": {
"command": "npx",
"args": ["-y", "@levitating/tensor-mcp@^5.0.0"],
"env": {
"TENSOR_API_KEY": "ak_live_your_key_here",
"TENSOR_BASE_URL": "https://your-instance.example.com"
}
}
}
}Writes are permanent
Four tools have consequences an MCP client cannot undo, and they are worth confirming with the user before calling:
create_transaction— the ledger is append-only. There is no update or delete for a transaction, because position state (quantity, average cost, cost basis, realized gain) is a non-invertible fold over it. A mistake is corrected by posting a compensating entry of the opposite type, never by editing.delete_position— hard-deletes the position together with its entire transaction ledger, valuation-event history, loan/fund terms, relations and attachments. To record an exit, usesell_all_positioninstead; that keeps the history.delete_bookmark_folder— destroys the folder row outright, and it is the one irreversible thingtensor:bookmarks:writegrants. Nothing filed under it is lost (its bookmarks are unfiled, its sub-folders promoted, in one transaction) but the folder and the filing decision it recorded are gone: there is no Trash behind it and no restore. Note it is the opposite ofdelete_file_folder, which refuses a non-empty folder — this one accepts one, so its reach is larger than the sibling it resembles.data_apply— writes a whole batch. Alwaysdata_dry_runfirst and read the preview.
Deletes on entities, accounts, assets and reference data are likewise immediate and unrecoverable. The work surfaces are deliberately softer: delete_file, delete_idea and delete_todo all move a row to a restorable Trash, and every permanent purge is UI-only. delete_todo replaced archive_todo in 2026-08, when Archive was removed from Todos — a rename with a real change behind it, and therefore a MAJOR bump. Bookmarks and tab groups joined them in the same shape: delete_bookmark and delete_tab_group are soft and restorable, and neither purge is a tool. Every Trash is the same in one way worth knowing: each empties itself 60 days after deletion, on one shared window and one sweeper. (This sentence used to name that as a difference between Todos and the other two, then as three surfaces; the registry owns the count, which is why one is not written here.) Still immediate: delete_todo_state, delete_todo_tag, delete_file_folder and delete_tab (one tab out of a live group — the GROUP is the recoverable unit, so a tab has no soft state of its own). The first three refuse while in use; once through, there is no undo.
Available Tools
The tables below are the complete list. For the authoritative inventory at runtime — names, descriptions and full input schemas — send a tools/list JSON-RPC request after initialize. (Inside this repo, listRegisteredToolNames() in tools.ts derives the same list; prefer either over copying a count into prose, which is how this document once came to advertise six tools that did not match the registry.)
Rollups, quotes, sync & search
| Tool | Description |
|------|-------------|
| get_portfolio_summary | Net worth, cost basis, unrealized P/L — computed server-side, optionally grouped |
| search_market_quotes | Find market-quote candidates for an asset |
| verify_market_quote | Resolve one symbol to a live price without writing |
| sync_prices | Reprice every linked asset (or a subset) |
| sync_asset_price | Reprice one asset |
| list_changes | Creates, updates and deletes since a timestamp |
| search_records | Site-wide ranked search across every record type; lanes are gated per read scope, so the key sees exactly its slice. Memos are the one lane no scope opens — they are session-only |
Portfolio
| Tool | Description |
|------|-------------|
| list_entities | List all entities (people, trusts, companies) |
| create_entity | Create a new entity |
| update_entity | Update an entity |
| delete_entity | Delete an entity |
| list_entity_relationships | List entity relationships |
| create_entity_relationship | Link two entities |
| update_entity_relationship | Update a relationship |
| delete_entity_relationship | Remove a relationship |
| list_accounts | List all accounts |
| create_account | Create a new account |
| update_account | Update an account |
| delete_account | Delete an account |
| list_assets | List all assets |
| get_asset | Get a single asset |
| create_asset | Create a new asset |
| update_asset | Update an asset |
| delete_asset | Delete an asset |
| list_asset_prices | List historical prices |
| update_asset_price | Record a new price |
| list_positions | List all positions |
| create_position | Open a new position. Pass costBasis instead of quantity/averageCost to open a fund capital account |
| update_position | Update a position |
| delete_position | Destructive. Hard-deletes the position and its whole ledger and history — see above |
| sell_all_position | Close/sell a position, keeping its history |
| list_position_relations | List position relations |
| create_position_relation | Link positions |
| delete_position_relation | Remove a relation |
| list_position_events | Valuation-event history for one position |
| list_events | Valuation-event history across all positions |
| list_transactions | List all transactions |
| create_transaction | Append to the ledger. Permanent — no edit, no delete; amount/quantity are positive magnitudes and direction comes from the type |
Reference Data
| Tool | Description |
|------|-------------|
| list_institutions | List institutions |
| create_institution | Create an institution |
| update_institution | Update an institution |
| delete_institution | Delete an institution |
| list_entity_types | List entity types |
| create_entity_type | Create an entity type |
| update_entity_type | Update an entity type |
| delete_entity_type | Delete an entity type |
| list_role_types | List role types |
| create_role_type | Create a role type |
| update_role_type | Update a role type |
| delete_role_type | Delete a role type |
| list_account_types | List account types |
| create_account_type | Create an account type |
| update_account_type | Update an account type |
| delete_account_type | Delete an account type |
| list_asset_classes | List asset classes |
| create_asset_class | Create an asset class |
| update_asset_class | Update an asset class |
| delete_asset_class | Delete an asset class |
Data Pipeline
| Tool | Description |
|------|-------------|
| data_dry_run | Step 1: validate a typed batch (batchId + ops) and get a persisted preview with a requestHash |
| data_apply | Step 2: execute the persisted dry-run by batchId + requestHash + confirm sentinel (never takes fresh ops) |
Files
The full feature: browse, upload (presign → PUT → confirm), organize, link, soft-delete. Deletion is a restorable Trash; the permanent purge and decryption-device enrollment are deliberately not here.
| Tool | Scope | Description |
|------|-------|-------------|
| list_files | tensor:files:read | Export manifest for backup/mirroring: human folder/filename paths, checksums, and — with urls — short-lived presigned download URLs. Encrypted files also release a per-file key when a registered deviceId is passed |
| browse_files | tensor:files:read (+ the target's read scope for targetType, and per row for links) | Folder/Trash-aware listing the way the Files page reads: by folder, attached record, or title/filename search, in any of the page's eight orders (sort). targetType reads the links table since the 2026-08 fold, so a todo, idea or memo target is a question it can answer. Links to records your key cannot read are omitted |
| get_file | tensor:files:read | One file's metadata and attachment links. Links to records your key cannot read are omitted |
| create_file_upload_url | tensor:files:write | Step 1 of the upload: presigned PUT URL + storage key (obey the echoed encryption terms) |
| confirm_file_upload | tensor:files:write (+ each links target's read scope) | Step 3: confirm the object landed and create the file row (+ links) in one transaction. The optional links array takes the same target types as attach_file_link — the six ledger records plus todo, idea and memo, never file — and since 2026-08 a target your key cannot read fails the confirm with a 403 rather than being attached quietly |
| update_file | tensor:files:write | Rename / move / annotate (title, folder, notes) |
| delete_file | tensor:files:write | Move to Trash. Soft — restorable; the purge is UI-only |
| restore_file | tensor:files:write | Restore from Trash |
| get_file_download_url | tensor:files:read | Presigned URL for one file's bytes (plus the key release for an encrypted file). Unauthenticated bearer access — every issuance is access-logged |
| list_file_links | tensor:files:read + the target's read scope | A file's attachments — the six ledger records plus a todo, an idea or a memo since the 2026-08 fold. Each row carries label, the target's resolved name, and an id that is the EDGE's id rather than the target record's. Rows your key cannot read are omitted, memos for every key |
| attach_file_link | tensor:files:write + the target's read scope | Attach a file to a record — the six ledger records plus todo, idea and memo, never file (idempotent). A target you cannot read is a 403, not a silent drop |
| detach_file_link | tensor:files:write + the linked target's read scope | Remove one attachment; the file is untouched |
| list_file_folders | tensor:files:read | List folders (nesting + optional record pin) |
| create_file_folder | tensor:files:write | Create a folder |
| update_file_folder | tensor:files:write | Rename / move / re-pin a folder |
| delete_file_folder | tensor:files:write | Delete an empty folder — refuses one still holding files or sub-folders |
Todos
Bodies and comments are TipTap JSON, never HTML — the write tools take plain text and wrap it. delete_todo is a soft delete to Trash and restore_todo undoes it; the permanent delete, emptying Trash and comment deletion are session-only.
| Tool | Scope | Description |
|------|-------|-------------|
| list_todos | tensor:todos:read | List todos with server-side filters (state, category, assignee, priority, tag, parent, linked record, search, trash) and sort. assigneeId and tagId take one id or several |
| get_todo | tensor:todos:read | One todo: body, state, canonical tags, links, sub-task counts. Links to records your key cannot read are omitted |
| get_todo_subtree | tensor:todos:read | The branch under a todo — counts and depth, for naming what a delete or a re-parent takes with it |
| get_todo_activity | tensor:todos:read | Activity feed, folded from the mutation audit log — a rolling 90-day window, not permanent history |
| create_todo | tensor:todos:write + each links target's read scope | Create a todo (plain-text body, wrapped to TipTap JSON; stateId from list_todo_states). The optional links array takes the same target types as attach_todo_link — the six ledger records plus todo, idea and memo, never file — and a target your key cannot read fails the create with a 403 rather than being dropped |
| update_todo | tensor:todos:write | Partial update; optional stale-write guard via expectedUpdatedAt |
| delete_todo | tensor:todos:write | Move the todo and its whole branch to Trash, atomically. Soft; restore_todo undoes it, and Trash self-empties after 60 days |
| restore_todo | tensor:todos:write | Take a todo out of Trash; cascade restores its deleted branch, withAncestors the chain above (409 without it) |
| set_todo_parent | tensor:todos:write | Re-parent (or promote to top level); cycles and over-depth are refused |
| reorder_todo | tensor:todos:write | Move within the manual order, optionally changing state |
| set_todo_tags | tensor:todos:write | Replace the todo's whole tag set |
| list_todo_comments | tensor:todos:read | The comment thread |
| create_todo_comment | tensor:todos:write | Comment (plain text, stored as TipTap JSON) |
| update_todo_comment | tensor:todos:write | Edit your own comment — comments are never deletable here |
| list_todo_states | tensor:todos:read | Workflow states with categories and usage counts |
| create_todo_state | tensor:todos:write | Create a workflow state |
| update_todo_state | tensor:todos:write | Update a state; category change refuses while in use |
| delete_todo_state | tensor:todos:write | Delete a state; refuses while todos hold it |
| list_todo_tags | tensor:todos:read | Tags with usage counts |
| create_todo_tag | tensor:todos:write | Create a tag |
| update_todo_tag | tensor:todos:write | Rename / recolor a tag |
| delete_todo_tag | tensor:todos:write | Delete a tag (removed from every todo) |
| list_todo_files | tensor:todos:read + tensor:files:read | Files attached to a todo. The rows are file rows, so the files read scope is required too |
| attach_todo_file | tensor:todos:write + tensor:files:read | Attach an existing file (upload via the files tools first) |
| detach_todo_file | tensor:todos:write + tensor:files:read | Detach a file; the file itself is untouched |
| list_todo_links | tensor:todos:read + the target's read scope | Records the todo is linked to — the six ledger records plus another todo, an idea or a memo (a file too, if a related edge exists; attachments are list_todo_files). Rows your key cannot read are omitted rather than refused, memos for every key |
| attach_todo_link | tensor:todos:write + the target's read scope | Link the todo to a record — the six ledger records plus todo, idea and memo; files go through attach_todo_file (idempotent) |
| detach_todo_link | tensor:todos:write + the linked record's read scope | Remove one of the todo's links |
Ideas
The capture inbox. An idea's body is a TipTap document since 4.2.0 — plain text in via body, plain text back out, with bodyJson alongside on reads; its comments are the same; its media are file rows behind the same upload machinery as Files. Delete is a restorable Trash; the purge and comment deletion are session-only. Since 4.1.0 an idea's record links are here too, the same three verbs the todo and file surfaces already had.
| Tool | Scope | Description |
|------|-------|-------------|
| list_ideas | tensor:ideas:read (+ the target's read scope for targetType) | The stream — filter by kind, tag, site, search, linked record; order it with sort (4.3.0); or, with trash, the deleted rows |
| get_idea | tensor:ideas:read | One idea with tags and media gallery |
| create_idea | tensor:ideas:write | Capture (needs a url, some text, or media; duplicate URLs are flagged, not refused). body is plain text in, document stored |
| update_idea | tensor:ideas:write | Partial update; tags replaces, media appends, body REPLACES the whole body document |
| delete_idea | tensor:ideas:write | Move to Trash. Soft — restorable; the purge is UI-only |
| restore_idea | tensor:ideas:write | Restore from Trash |
| list_idea_comments | tensor:ideas:read | The comment thread |
| create_idea_comment | tensor:ideas:write | Comment (plain text, stored as TipTap JSON) |
| update_idea_comment | tensor:ideas:write | Edit your own comment — comments are never deletable here |
| list_idea_tags | tensor:ideas:read | Every tag in use, with counts |
| list_idea_sites | tensor:ideas:read | Every source site, with counts |
| get_idea_media_url | tensor:ideas:read + tensor:files:read | Presigned URL (+ key release for encrypted media) for one gallery object; access-logged. An idea's media IS a file row, and the key release is permanent, so the files read scope is required too |
| create_idea_upload_url | tensor:ideas:write | Presign a media upload; confirm it via create_idea / update_idea media |
| list_idea_links | tensor:ideas:read + the target's read scope | Records the idea is linked to — the six ledger records plus a todo, another idea or a memo (a file too, if a related edge exists; the gallery is get_idea → media). Each row's kind says whether a person made it (related) or the reconciler did (mention). Rows your key cannot read are omitted rather than refused, memos for every key |
| attach_idea_link | tensor:ideas:write + the target's read scope | Link the idea to a record — the six ledger records plus todo, idea and memo; files are the gallery, not a link (idempotent) |
| detach_idea_link | tensor:ideas:write + the linked record's read scope | Remove one of the idea's links. Two kinds refuse with a 409 instead of being removed: a gallery attachment (wrong door for a photo) and a mention (the reconciler owns it — edit the prose) |
Bookmarks
The filed half of Web: pages somebody decided to keep, in a folder tree. /api/bookmark-folders is a SIBLING resource rather than a sub-path — one scope opens both. Delete is a restorable Trash; the purge is session-only. A duplicate URL is flagged (duplicateOf), never refused.
| Tool | Scope | Description |
|------|-------|-------------|
| list_bookmarks | tensor:bookmarks:read | The library, newest first. folderId is three-valued — an id, 'none' for the unfiled, or omitted for everything — and trash gives the deleted rows |
| get_bookmark | tensor:bookmarks:read | One bookmark, with the normalizedUrl duplicate detection compares |
| create_bookmark | tensor:bookmarks:write | File a page. Only url is required; no folder means unfiled, which is a resting state |
| update_bookmark | tensor:bookmarks:write | Partial update — title, url, folder, excerpt, favicon. folderId: null unfiles |
| delete_bookmark | tensor:bookmarks:write | Move to Trash. Soft — restorable; the purge is UI-only |
| restore_bookmark | tensor:bookmarks:write | Restore, back into the folder it was filed in |
| list_bookmark_folders | tensor:bookmarks:read | The folder tree |
| create_bookmark_folder | tensor:bookmarks:write | Create a folder, optionally nested |
| update_bookmark_folder | tensor:bookmarks:write | Rename or re-parent. A cycle or a depth ceiling refuses with 409 saying which |
| delete_bookmark_folder | tensor:bookmarks:write | Destroy the folder and nothing else: its bookmarks are unfiled, its sub-folders promoted. No undo — a folder is a filing decision, not a Trash surface |
Tab groups
Tabs parked from the browser extension, kept together and in order. Scoped SEPARATELY from bookmarks on purpose: a synced group is in effect a partial browsing history. The GROUP is the unit — there is no per-tab read, and no tab has a Trash of its own.
| Tool | Scope | Description |
|------|-------|-------------|
| list_tab_groups | tensor:tabs:read | Every group, hydrated with its tabs. Starred first. limit/offset page the groups; a group's tabs are never truncated |
| get_tab_group | tensor:tabs:read | One group with its tabs in order |
| create_tab_group | tensor:tabs:write | Park 1–500 tabs as one group. One call carries the whole group — the rate limit makes per-tab posting unworkable |
| update_tab_group | tensor:tabs:write | name, locked, starred — and deliberately not the tabs, so "which tabs did I have" stays answerable from the audit trail |
| delete_tab | tensor:tabs:write | Remove one tab from a live group. Immediate; the group is the recoverable unit |
| delete_tab_group | tensor:tabs:write | Move the whole group to Trash. Soft — restorable; the purge is UI-only |
| restore_tab_group | tensor:tabs:write | Restore the group, tabs and all |
Scopes
Your API key needs the following Tensor scopes, attached when the key is created. Deny-by-default: a key with no scopes reaches nothing.
| Scope | Grants |
|-------|--------|
| tensor:portfolio:read | Read entities, accounts, assets, positions, transactions, events, relations — plus the portfolio summary, change feed, and market-quote lookups |
| tensor:portfolio:write | Create/update/delete portfolio resources, sell-all, record a price, trigger a price sync |
| tensor:reference:read | Read institutions, entity types, role types, account types, asset classes |
| tensor:reference:write | Create/update/delete reference data |
| tensor:files:read | Browse files and folders, read metadata and links, mint download URLs, and the export manifest |
| tensor:files:write | Upload (presign + confirm), rename/move, folders, record links, soft-delete/restore |
| tensor:todos:read | Read todos, subtrees, activity, comments, workflow states, canonical tags and record links. A link row also needs its target type's own read scope; the attached-files list needs tensor:files:read outright |
| tensor:todos:write | Create/update todos, soft-delete to Trash and restore, reorder/re-parent, comments, canonical tags, states, attachments. Attaching or linking also needs the other end's read scope; the Trash purge and comment deletion are not reachable at all |
| tensor:ideas:read | Read the ideas stream, tag/site rails, comments and record links. Not media URLs on their own — an idea's media is a file row and that route releases its decryption key, so get_idea_media_url needs tensor:files:read as well |
| tensor:ideas:write | Capture/update ideas, soft-delete to Trash and restore, comments, media uploads and record links. A link write also needs the target's read scope; the Trash purge and comment deletion are not reachable at all |
| tensor:bookmarks:read | Read filed bookmarks and the bookmark folder tree. The tree is a sibling resource of /api/bookmarks, not a sub-path, and this one scope opens both |
| tensor:bookmarks:write | Create/update bookmarks, move them between folders, folder create/rename/re-parent/delete, soft-delete to Trash and restore. The purge is not reachable at all |
| tensor:tabs:read | Read tab groups and the tabs inside them. Separate from tensor:bookmarks:read on purpose — a synced group is in effect a partial browsing history, so a key issued to read the pages you filed does not also read every page you parked |
| tensor:tabs:write | Create a group with its tabs, rename/lock/star, remove a tab, soft-delete a group to Trash and restore it. The purge is not reachable at all |
| tensor:data:read | Retrieve a persisted dry-run batch |
| tensor:data:dry_run | Run data pipeline dry-runs |
| tensor:data:apply | Apply data pipeline operations |
| tensor:data:admin | Reserved; no endpoint requires it today |
search_records needs no scope of its own — each result lane requires that domain's read scope, lanes the key does not cover are silently omitted, and a key covering no lane gets 403.
The Tensor user who issued the key is the ceiling: a key can never do more than that user could do in the browser.
