npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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_ideamedia), 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-issuingtensor: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/mcp

Point 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.0

Claude 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, use sell_all_position instead; that keeps the history.
  • delete_bookmark_folder — destroys the folder row outright, and it is the one irreversible thing tensor:bookmarks:write grants. 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 of delete_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. Always data_dry_run first 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_ideamedia). 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.