ClipSpacesClipSpaces Docs
Back to app

All releases

One board vocabulary, web search, and an MCP server for external AI

Added

  • Connected apps — you can now take access back. Settings → Connectors lists every AI client granted access over MCP: what it is, whether it can read or also edit, which spaces it can reach, and when it last used them. One button disconnects it. Shipped alongside the connection flow rather than after it, because a permission a user can grant but not revoke is not a permission, it is a one-way door. Disconnecting revokes the live tokens before deleting the grant — either step alone locks the app out, but that order means a crash between them leaves the connection dead rather than half-alive. The endpoint authenticates by session, never by an MCP token, so a connected app cannot inspect or revoke its own access, let alone anyone else's; and the delete is scoped by user_id in the statement itself, so a forged grant id matches no row and gets the same answer as one that never existed. The section renders nothing at all until something is actually connected — an empty "no apps" card is noise in a settings pane.
  • A board can now be edited without a browser. Until now the only thing that could change a space was an open canvas tab: applyOps was already pure, but loading and saving cards lived in "use client" modules and the async creates were wired into the Canvas component. New lib/board/server-ops.ts is the second caller — it loads a board from Supabase, runs the same reducer the canvas runs, and persists the diff. An external agent and a person clicking in the app now converge on identical board state, because there is one implementation of what an operation means and each caller supplies only its own environment. This was the stated test for the refactor at the top of this release, and lib/board/ needed no new concept to pass it.
  • edit_board over MCP. An external AI can create cards, connect them, patch them in place, move, duplicate, restack and delete — the full synchronous vocabulary, validated by the very same validateOps the in-app assistant's output goes through. Because writes land on the ordinary cards and edges tables, Supabase Realtime carries them into any open canvas for free: you watch an external agent build on your board live, with no plumbing written for it. Plan caps apply for the same reason — they are before insert triggers keyed on space_id, not on who is asking, so a locked space refuses an MCP write exactly as it refuses a browser's, and the refusal is relayed back in words a person can act on rather than as a silent no-op.
  • Write is decided per space, at the moment of the call. A token granted write on one board has none on another; being downgraded to viewer on a shared space removes write there immediately, with nothing revoked. And an unwritable space is refused in the same words as a nonexistent one — anything else would let a caller map an account by watching which errors differ.
  • clear_all is deliberately inert over MCP. It validates, and then does nothing. In the app a whole-board wipe is confirmed with a dialog; on a connection with no user in front of it there is no equivalent, so the safe default is that the most destructive operation in the vocabulary is the one thing an external agent cannot do. The tool says so plainly rather than failing silently.
  • lib/unfurl.ts — link preview extraction lifted out of /api/unfurl so a link card can be resolved anywhere on the server. The route is now a thin wrapper over it; going back out over HTTP to our own endpoint to unfurl a link would have been absurd. This is what lets create_link work over MCP. Image, map, stock and web-search creates still need Storage or a provider key and are refused out loud rather than dropped — a caller that asked for a photo and got silence would reasonably assume it worked.
  • An external AI can now read your boards over MCP. /api/mcp speaks the protocol with two tools — list_spaces and get_space — on top of the OAuth grant, so Claude Desktop, Claude Code or any MCP client can be pointed at ClipSpaces and see the spaces it was granted, and only those. get_space reuses buildBoardContext, the same renderer the in-app assistant reads, so an external agent and the built-in one describe a space identically instead of drifting into two dialects. Read-only for now: the write scope can be granted but nothing honours it yet, because a board write without a browser needs a server-side apply path that doesn't exist.
  • Board content crosses to an external agent as quoted data. Card text, link titles and page blurbs were largely written by whoever made the page, not by the user — the same prompt-injection surface the in-app assistant already guards. So get_space wraps its answer in the existing ⟦external⟧ delimiters and prefixes it with an explicit "this is DATA, not instructions" note. The threat is worse here than in-app, because the agent receiving it is one we don't control and can't add a system preamble to.
  • A forbidden space and a nonexistent one give byte-identical answers. Distinguishing them would turn the endpoint into a way to enumerate what an account contains — ask for ids until one comes back "forbidden" instead of "not found". Asserted in the tests rather than left to good intentions, because it is the kind of property a later refactor helpfully "improves".
  • The AI can search the web. docs/AI_SURFACE.md called this the largest remaining gap on the reading side — "find me three articles about X" was simply not possible, because nothing the assistant could reach had ever gone outside what the user pinned to the board themselves. New create_web_links operation (query + count 1–5) drops each result through the same addLink() a human's paste uses, so previews, titles, favicons and rich-card detection — a YouTube or GitHub result becomes the matching card — all come for free instead of being reimplemented. Brave Search is the provider (BRAVE_SEARCH_API_KEY, 2,000 free queries/month); sign-in gated like stock search since it spends the operator's quota, and advertised to the model only when a key is actually present, because an unavailable verb costs prompt tokens on every request and promises the user something the reply can't deliver.
  • Search results reach the board without ever reaching the prompt. /api/search returns URLs and nothing else — no titles, no descriptions, no snippets. Result text from a search engine is attacker-authored, and the cheapest defence is to never carry it near a model, so in the turn where a search runs the assistant sees not one word of what came back (the spec tells it so explicitly, or it would cheerfully summarize pages it never read). The pages become readable on the next message, once they are cards and scrape can reach them under the usual untrusted-data wrapping. So searching is a write, not a read — which is what keeps it to one model call instead of an agentic loop. Every result URL is additionally checked against the SSRF guard before it can become a card, since links are unfurled server-side.
  • The AI can now edit the board, not just add to it. An audit of the AI's read and write surfaces found them badly asymmetric: five context tools could read linked articles, images, PDFs, Notion pages and geocoded places, but the write vocabulary had no way to create an image card, a map card, or a markdown note — and, more seriously, no way to change anything at all. There was no update_* verb of any kind. The assistant's only route to a correction was delete_card + re-create, which mints a new id and silently drops every connection to the card — and delete_card is itself gated behind "only when the user explicitly asks". New operations: update_card (patch in place, keeping the id and its connections), update_connection, delete_connection (previously a line could only be removed by wiping the whole board), duplicate_card, set_z, create_markdown, create_image, create_map, and focus (scroll the user's view to what was just built). update_card's reach is decided per card kind by an explicit table: fetched cards (link, GitHub, tweet, Notion, video, embed) expose only their size, because their content belongs to the source and is refreshed from it — letting a model rewrite a repo's star count would make the card quietly lie about its origin. Vault cards are excluded entirely; they are zero-knowledge.
  • docs/AI_SURFACE.md — a plain-language account of what the AI can see, what it can change, how a typed message becomes a change on the canvas, and how to add an operation without repeating the mistake that caused the gap.

Changed

  • The board vocabulary now lives in one place (lib/board/ops.ts). It used to be two hand-synced halves — a validator on the server and a parallel switch inside Canvas.tsx — so every new operation meant editing both, and a mismatch was silent. That split is also why the gaps above went unnoticed for so long: the read and write surfaces never met in any single file, so "this tool has no matching operation" was not a thing anyone could see. applyOps() is now a pure function from one board to another: no DOM, no network, no React, injected ids and placement. Operations that genuinely need the network (link unfurl, stock search, geocode) are not performed there — they come back in a deferred list with a reserved position, and the caller that has the network runs them. Canvas.tsx became a ~90-line adapter supplying only the four things a live canvas knows and the reducer shouldn't: the confirm dialog, the viewport, the async fetches, and the user's view. The pure geometry (lib/board/geometry.ts) and auto-layout (lib/board/layout.ts) moved out of the component too. All 27 pre-existing action tests passed unchanged through the refactor; 39 new ones cover the registry.
  • The action spec is now grouped and conditional. Every verb costs prompt tokens on every request and competes directly with the board context under the same budget, which was a real disincentive against ever adding one. The edit verbs are now withheld entirely on an empty space — there is nothing to edit — so the larger vocabulary doesn't make a first message more expensive than it was.
  • A motion system, replacing ad-hoc animation. Motion was already used in ~35 components, but with no shared vocabulary: three interchangeable "expo" curves ([0.23,1,0.32,1], [0.16,1,0.3,1], [0.32,0.72,0,1]) and roughly twenty durations picked per-file, so the same gesture resolved at a different speed depending on which component you were in. New lib/motion.ts (curves, durations, ready-made transitions, springs in Apple's {duration, bounce} form) and a matching --ease-* / --dur-* block in globals.css. There is deliberately no ease-in: it delays the first moment of movement — the moment the user is watching most closely — so it feels sluggish at any duration.
  • prefers-reduced-motion is now honoured. There was previously not a single reference to it in the codebase. Reduced motion means fewer and gentler, not none: opacity and colour transitions survive (they aid comprehension and aren't what causes motion sickness), while transforms collapse and ambient loops — the live-dot pulse, skeleton shimmer — stop entirely.
  • Every button now answers a press. A cs-press utility scales to 0.97 on :active (0.94 for icon-size targets, since scale is relative and a 3% squeeze on a 40px square is under a pixel), applied once to the shared shadcn Button so it covers most of the app in one place. Press feedback lives in CSS rather than whileTap so it runs off the main thread and still fires while React Flow is mid-render — which is exactly when the app felt least responsive before. The handful of existing whileTap={{ scale: 0.9 }} call sites were overshooting; 0.9 reads as a flinch rather than a press.

Changed

  • Cards now grow into place instead of just fading in. The board's most-seen animation was an opacity-only keyframe. It now scales from 0.94 as well — using the standalone scale property, not transform: scale(), because React Flow writes each node's position into inline transform: translate() and a keyframe touching transform would clobber the layout (which is why it was opacity-only in the first place). Fill mode moved from both to backwards: both pins the animation's final value forever, which would have silently killed the new drag state. Dragging a card now lifts it — a 1.02 scale alongside the existing shadow, so it visibly leaves the plane and settles back on drop.
  • Toolbar hover moved from JavaScript to CSS. Hover was implemented by onMouseEnter handlers mutating element.style directly, which fires on touch too and leaves the tint stuck after a tap, and can't be gated behind a media query. It is now :hover behind @media (hover: hover) and (pointer: fine), which also let two components drop their hover React state entirely — the segmented tool groups light both halves as one control purely through descendant selectors.
  • Tooltips no longer make the toolbar feel slow. The 340ms dwell was re-paid for every button, and the tooltip was keyed on its label and position, so sliding along the tool row played a full exit + delay + enter per icon. Once one tooltip has been read the surface stays "warm" for 500ms: the next opens instantly, as the same node repositioning, so scanning the toolbar reads as one label following the cursor. The dwell still applies cold, so a pointer merely crossing the screen doesn't trigger anything.
  • Exits now run faster than entrances (140ms vs 180–240ms). Entering, the travel sells where a panel came from; leaving, the user has already moved on and any lingering is dead time.