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_idin 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:
applyOpswas already pure, but loading and saving cards lived in"use client"modules and the async creates were wired into the Canvas component. Newlib/board/server-ops.tsis 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, andlib/board/needed no new concept to pass it. edit_boardover 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 samevalidateOpsthe in-app assistant's output goes through. Because writes land on the ordinarycardsandedgestables, 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 arebefore inserttriggers keyed onspace_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_allis 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/unfurlso 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 letscreate_linkwork 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/mcpspeaks the protocol with two tools —list_spacesandget_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_spacereusesbuildBoardContext, 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_spacewraps 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.mdcalled 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. Newcreate_web_linksoperation (query + count 1–5) drops each result through the sameaddLink()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/searchreturns 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 andscrapecan 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 wasdelete_card+ re-create, which mints a new id and silently drops every connection to the card — anddelete_cardis 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, andfocus(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 parallelswitchinsideCanvas.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 adeferredlist with a reserved position, and the caller that has the network runs them.Canvas.tsxbecame 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. Newlib/motion.ts(curves, durations, ready-made transitions, springs in Apple's{duration, bounce}form) and a matching--ease-*/--dur-*block inglobals.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-motionis 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-pressutility 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 shadcnButtonso it covers most of the app in one place. Press feedback lives in CSS rather thanwhileTapso 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 existingwhileTap={{ 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
scaleproperty, nottransform: scale(), because React Flow writes each node's position into inlinetransform: translate()and a keyframe touchingtransformwould clobber the layout (which is why it was opacity-only in the first place). Fill mode moved frombothtobackwards:bothpins 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
onMouseEnterhandlers mutatingelement.styledirectly, 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:hoverbehind@media (hover: hover) and (pointer: fine), which also let two components drop theirhoverReact 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.
Added
-
Plan limits are now enforced — and live. Applied to the hosted Supabase (
clipspaces) viasupabase db pushon 2026-07-26; the storage trigger onstorage.objectscreated successfully rather than hitting the ownership fallback. The caps indocs/PRICING.mdhave been declarative since billing landed — priced, rendered in Settings → Plans, resolvable throughlib/billing/entitlements.ts, and applied by nothing. A Free account could create unlimited spaces.20260725000100_plan_enforcement.sqlcloses that.Why Postgres and not a route. Cards, edges, drawings and comments are written straight from the browser through the Supabase client under RLS; there is no server route to hang a check on. So the check lives where the write lands —
before inserttriggers onspaces,space_members,space_invites, vaultcards, andstorage.objects. AI tokens, frontier models and Notion stay route-side, where a route does exist.The ladder now exists twice, which is the one real cost of this design:
lib/billing/plans.tsprices it and a newprivate.plan_limitstable enforces it, because a trigger cannot import TypeScript.tests/billing-enforcement.test.tsparses the migration and asserts the two match field by field — change a number in one place and the suite fails until you change the other. It also pins the SQL port of the entitlement resolver (entitling statuses, the 3-day grace) to the TypeScript one.Nothing is ever deleted to fit a cap. An account already over the space cap keeps every space; the ones beyond it go read-only — oldest-first-wins, ordered by
(created_at, id)so the order is total and which spaces are writable can't flip between calls. They still open, read and export; the space list marks them Read-only andcanEditgoes false so the tools grey out rather than letting someone edit into a wall of silently discarded writes.DELETEis never blocked, since deleting is how a user gets back under the cap.Existing collaborators are never evicted — only the next join is blocked. It's the one cap whose victim would be a third party who did nothing wrong.
Two subtleties worth recording. Members and pending invites are counted together, or you could invite twenty people and let them accept one at a time, each insert passing a check the others weren't in. But the invitee's own invite is excluded when they accept it, because
claim_space_invitesinserts the membership while the invite row still exists — counting both meant the last seat on a plan could never be filled and an invited person was permanently locked out of a space they'd been invited to.Every rejection raises through one helper with SQLSTATE
CS429and a machine-readableclipspaces.limit:<cap>:<value>message.lib/billing/limit-error.tsis the only thing that parses it, and it returns null for anything else — a database outage must never render as a paywall. -
Hitting a limit tells you what to do about it. Rejections surface as one toast carrying an Upgrade button that opens Settings → Plans, instead of a silent failure or a raw Postgres message.
useToastgains an optional action (any toast may carry one, deliberately singular; actionable toasts also linger 7s instead of 3.8s, since the default isn't long enough to notice a button and reach it). Because the rejections surface insidelib/supabase/*.ts— non-React modules four layers below anything that could render — the data layer emits on a small bus (lib/billing/limit-bus.ts) thatUpgradePromptProvidersubscribes to, rather than threading anonLimitcallback through every signature on the path. The bus throttles to one toast per cap per 10s, or a locked board would bury the screen in identical messages every time the debounced card sync retried.
Changed
- Clip AI's token allowance is per-plan.
lib/ai/credits.tsresolved a single deploy-wideAI_CREDIT_LIMITfor everyone; it now reads the caller's plan throughcreditPolicyFor()— Free 50k/day, Personal 1M/month, Studio 5M/month.AI_CREDIT_LIMITandAI_CREDIT_WINDOW_HOURSsurvive as the self-host override: an operator with no billing wants one budget for everyone, so setting them pins every user to it. Own-key users remain unmetered — they cost the operator nothing. - Frontier models on the host key are Studio-only. New
isFrontierModel()classifies by a deny-list of economy models rather than an allow-list of premium ones, so a model we haven't heard of costs the operator nothing by default instead of being served to everyone until someone notices the bill. The host's own configured default is always exempt — whatever the operator runs as the base model is the base model. A gated request is refused rather than quietly downgraded: answering as a different model than the one the user picked is worse than saying no. Checked before the credit gate, so the error names the real reason. - Notion sync requires a paid plan. Gated on
importandexport, and onconnectso a Free user isn't sent through an OAuth dance the plan can't finish.statusanddisconnectstay open — a downgraded user must still be able to see and remove an existing connection.
Fixed
- The token-usage bar had no visible track in light mode. Settings → AI drew the meter's
unfilled track in
--bg-input, which in the light theme is the same#f1f0edas the card's--bg-elevatedbehind it — so at low usage the bar read as a floating stub with no scale to judge it against. The track is now--border-med, a translucent ink that separates from the card in both themes. - @-mentions in comments never appeared. The suggestion menu was an absolutely-positioned child
of the thread card, which sets
overflow: hidden— on a brand-new comment the composer is the card's topmost element, so the menu rendered above the card's edge and was clipped away entirely. Typing@looked like a dead key. It now renders through a portal positioned off the composer's viewport rect (components/comments/mentions.tsx), so no ancestor can clip it, and it flips below the field when the thread sits near the top of the screen. The mention query also runs to the end of the line instead of stopping at the first non-word character, so people with a space in their name ("Ada Lovelace") are reachable. - A stored mention was inert.
mentions[]was written into the comment'sdatablob and never read back — the body rendered as flat text. Mentions now render as accent chips, with your own highlighted in solid accent so being mentioned is visible at a glance. - Invited-but-not-joined people looked like a broken feature. Only rows in
space_memberswere ever mentionable, so someone you'd just shared a board with simply wasn't in the list. Pending invites now appear in the picker, greyed out and labelled invite pending (they have no account yet, so there's no user id to store).space_invitesSELECT is loosened from owner-only to any space member so every member sees the same list; writes stay owner-only. - The AI chat scrolled in from the top on every reopen. The scroll container lives inside
{open && …}, so closing the panel destroys it and reopening mounts a fresh one atscrollTop: 0— butscrolledChatRefsurvived the close, so the pin-to-bottom effect read "same chat, already settled" and smooth-scrolled from the top, which looked like the history was lazily loading in. The ref is now cleared when the panel closes, so a reopen lands at the newest turn instantly.
Added
-
Paid plans — Paystack billing (sandbox). Settings → Plans is live instead of a "Coming soon" tease: it renders the catalog in
lib/billing/plans.tsagainst the caller's real subscription, with a monthly/yearly toggle, and hands off to Paystack's hosted checkout to upgrade or its hosted Subscription Preferences page to change a card / cancel. Test mode is just a key swap — setPAYSTACK_SECRET_KEY=sk_test_…plus the fourPAYSTACK_PLAN_*codes and it's a sandbox; with no key configured, billing is off and the tab degrades to the old read-only catalog.Provider choice is forced by entity domicile, not preference: Stripe doesn't onboard merchants in most of Africa, so Paystack (which Stripe owns) is the rail. Every billing table is therefore provider-neutral —
provider+ opaqueprovider_*_id— so adding a merchant-of-record later for cross-border VAT is a second adapter, not a migration.New
public.billing_customers,public.subscriptionsandpublic.billing_eventstables (migration20260724000300_billing), all service-role write only, with users able to read only their own row. New routes underapp/api/billing/:subscription(GET current entitlement),checkout,portal,webhook.lib/billing/entitlements.tsis the single resolver turning a user id into caps — includinggetSpaceLimits(), which resolves throughspaces.owner_id, because the model is owner-pays, not seats (there is no org entity to sell seats against, and collaborators cost us almost nothing).Robustness details worth keeping: the checkout route never accepts a price, only a plan id; the webhook verifies HMAC-SHA512 over the raw body rather than a re-serialized one (Paystack's own Node sample gets this wrong) and dedupes on the signature, since Paystack events carry no event id; entitlement is granted by
charge.success, which fires on renewals too, so a dropped event self-heals; user resolution has three fallbacks so out-of-order delivery can't strand a payment; and the resolver fails to Free rather than to an error, with a 3-day grace pastcurrent_period_endso a late webhook can't downgrade a paying customer.tests/billing.test.ts(18 tests) locks the catalog contract — an unrecognised plan id must degrade to Free, never to a paid tier — and the signature boundary.vitest.config.tsnow aliasesserver-onlyto a stub so server modules are unit-testable at all.Enforcement is not wired yet: the caps are declared and resolvable, but nothing applies them.
docs/PRICING.md§5 lists where each must land — mostly Postgres triggers, since cards/edges/drawings are written client-side under RLS with no server route to guard. -
AI chats are private by default, and shareable read-only. A space's chats used to be space-scoped: every member could read and post into everyone's threads with no locking, so two people prompting on a shared board interleaved into one history.
ai_conversationsnow carriesuser_id+shared; RLS returns your own chats plus any a co-member has explicitly published, andai_messagesinserts are restricted to the thread's author. A shared chat is therefore a read-only window onto someone's thread — there is no such thing as a conflicting prompt. The sidebar gets a lock/share toggle per chat, marks other people's chats with their face and an eye icon, and swaps the composer for a read-only notice with a "New chat" escape hatch.ai_messagesjoins the realtime publication so a shared chat streams in live rather than sitting frozen. Pre-existing chats are handed to the space owner and left shared, so nothing vanishes. -
Real profile avatars. New
profiles.avatar_url, synced fromauth.usersmetadata (Google OAuth supplies one; the trigger coalesces so a provider that stops sending a picture can't wipe it) and backfilled for existing users. Onecomponents/UserAvatar.tsxreplaces the three hand-rolled avatar implementations that had accumulated in the peers dock, share popover and comment thread — with a sharedAvatarStackfor overlapping rows, and the initials-on-hashed-color fallback intact for email-OTP accounts and broken image URLs. Faces now appear on comment pins, comment rows, the mention picker, the presence stack, live cursor labels, the share list and shared AI chats, so people on a shared board are actually distinguishable.
Changed
- Chat-row actions collapsed into one overflow menu. Adding a share toggle had pushed the AI
sidebar row to four hover-revealed icon buttons plus two status glyphs, crowding out the chat title
they were meant to describe — and the share toggle used
--livegreen, which is reserved for presence, not brand chrome. There is now a single⋯trigger opening a shadcnDropdownMenu(Share with board / Make private · Rename · Export as JSON · Export as Markdown · Delete chat), which also replaces the hand-rolled export popover — one menu surface instead of two, with real keyboard navigation and a portal that can't be clipped by the scrolling sidebar. The remaining shared / read-only markers step down to tertiary ink. - Comments redesigned to a cleaner, more familiar layout. Both surfaces now follow the same structure: an avatar gutter, name + timestamp on one line, the body under it, and a quiet action row — instead of the previous cramped stack. The inline thread widens to 320px, indents replies under their root, folds long threads behind See N earlier replies, and gets a proper composer (your avatar, a growing field, a round send button that only lights up when there's something to send). The all-comments Sheet gains a counted title, an Active / Newest sort toggle and Open / Resolved tabs with live counts, replacing the two stacked sections that buried open threads under resolved history. Comment pins now show the author's face rather than an initial, with a reply-count badge.
- Comment tools are grouped like the other toolbar families. The two loose comment buttons are
now one segmented control matching Text and Media: the icon half toggles pin mode, the caret half
opens a flyout holding Comment and All comments. The unresolved badge rides on the main half.
New
ActionGroupincomponents/Toolbar.tsx— the command-shaped sibling ofToolGroup.
Added
- Pricing & distribution plans (docs). Two new planning docs.
docs/PRICING.mdproposes an owner-pays (not seat-based) model — there is no org/team entity in the schema, only per-spacespace_members, so a space's capabilities resolve from its owner's plan. Free / Personal $6 / Studio $14, with BYO AI keys unlimited on every tier (they cost us nothing) and host-key tokens as the metered axis, extending the existinglib/ai/credits.tsmetering. It flags that space/storage/collaborator caps must be enforced by Postgres triggers, since cards, edges and drawings are written client-side under RLS with no server route to guard. (Its payment-provider section originally recommended Paddle; §4 has since been rewritten around Paystack — Stripe and every MoR option need an entity in a supported country.)docs/DISTRIBUTION.mdcovers the desktop app (Tauri v2 over Electron, with a WebGL/WKWebView spike as the gate) and Docker self-host rules, identifying three blockers:NEXT_PUBLIC_*is inlined at build time so a published image can't carry an operator's config,<Analytics/>is mounted unconditionally inapp/layout.tsx, andnext.config.mjslacksoutput: "standalone".