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,
nottransform: 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.