UpLinked, an AI LinkedIn content platform rebuilt from Bubble on Convex
A LinkedIn writing tool for Dutch entrepreneurs, moved off Bubble onto Convex with six agents, a versioned prompt database, three models writing every post, and 196,000 rows carried across.
uplinked.appthe appthe thought experiment
UpLinked is Bart Gommers’ product: an AI marketing manager for LinkedIn, for Dutch entrepreneurs who are good at their trade and would rather not write about it. A user answers an interview about their week, the product writes the posts, plans them on a calendar and publishes them to LinkedIn. It began as a Bubble app at my.uplinked.app with 106 users, 428 profiles and 3,199 posts in it, and Bubble had run out of road, so the product was rebuilt from the schema up on Convex: a React 19 PWA in front of 175 tables, 25 cron jobs and six AI agents. Four of us built it, three on the front end; I did the backend, the agents, the prompt system, the migration and the billing, and merged the pull requests.
Prompts live in a second database
Every prompt, every tool description, every model choice and every post-type template lives in a second Convex deployment, prompts/, with fifteen tables of its own and an admin screen at /admin/prompts. An edit goes into a draft version. Promoting it regenerates a compiled bundle, the main app fetches /llm/bundle and caches it for sixty seconds, so a prompt change reaches production without a deploy and inside a minute. Rollback is pointing llm_settings.currentVersion at the previous record.
The constraint is the tools. An agent’s tools have argument schemas the code depends on, so those are fixed in a registry in the code, and only the description and the argument descriptions are editable. That split is what lets someone who is not a developer change how the interview agent behaves without being able to break a tool call.
Models are chosen the same way. Everything goes through OpenRouter, and each agent has a model record in the CMS with a model id, a temperature and a reasoning level. The fallbacks in the code say what the defaults were: Claude Sonnet 4 for the conversational agents, and for the ghostwriter three models on the same brief at once, Claude Sonnet, GPT-4o and Gemini Flash, so a user gets three versions of a post and the one they pick is recorded against the model that wrote it. Images are Gemini 3 Pro. System prompts are marked for Anthropic prompt caching through OpenRouter’s cache_control.
The pieces and how they talk. Prompts, tools and model choices are edited in the second deployment and reach the first as one compiled bundle; every model call goes through OpenRouter and every session leaves a trace in Langfuse.
An interview that survives a closed tab
The thing a user mostly does is talk to the context agent, which interviews them about the week’s posts, one slot at a time, until it has enough to hand to the ghostwriter. A turn takes ten to sixty seconds, and a Convex action tied to a browser connection dies with it. So no public action does the work: it validates, checks the usage limit, schedules an internal action for zero milliseconds later and returns. The internal action loads the bundle, opens or continues the Langfuse trace, builds the agent with its tools and streams the answer into the thread, and the browser reads the thread through a live subscription. Closing the tab loses nothing.
Two things went wrong with this, and users saw both. The scheduler can fire twice, and for a while some sessions opened with the agent greeting them twice. The fix is one mutation, claimInitStreaming, that flips a flag on the session atomically; whoever comes second gets false and goes home. Then the double greeting came back for a different reason. The agent library stores a turn that goes text then tool call as one message, but while it streams it shows up as two with different step orders. My first fix kept only the first assistant message per order, which also threw away real messages, so an answer could appear and then vanish. The fix that stayed dedupes parts instead of messages: tool calls by id, keeping whichever copy has output, text by content.
A stream that hangs is worse than one that fails, because the typing indicator never stops and there is nothing to click. Every streaming action now schedules a watchdog ninety seconds ahead before it does anything, cancels it on completion, and if the watchdog fires it aborts whatever streams are still open on that thread. The first version of that simply ended the answer mid-sentence; now a try-again link sits under the cut-off message, because a watchdog that ends a slow answer with no way back is only a different failure.
The session record the agent works on. The agent never sees a database; its six tools move slots between these statuses and write the summary and the labels, and start_writing is the only tool that leaves the phase.
Three versions, one trace
Langfuse gets one trace per session and one generation per turn, with the tool calls, the token counts and the cost; the ghostwriter has a trace of its own, stored on the post rather than the session, because it runs after the conversation is over. A thumbs up or down on a message or a version is a score on that trace, 1, minus 1, or 0.5 for “selected”, sent from a scheduled action so that a Langfuse outage cannot fail a mutation. Every feedback record also carries the prompt bundle version that was live, so a thumbs down can be traced to the prompt that earned it. When a version is promoted, the prompts deployment pushes the changed prompts to Langfuse too, with the version name as the commit message.
196,000 rows out of Bubble
Bubble’s API returns fifty items at a time, so the data left as seventeen CSV exports. The posts file was half a gigabyte, too big for a Convex action, so the browser parses it with PapaParse and does the scoping. My first design then uploaded everything as one JSON blob of two hundred megabytes and the action that read it died with a “Transient error”, which is what Convex says when it runs out of memory. The rewrite splits the data per profile into chunks of fifty to five hundred kilobytes, runs one core job for users, subscriptions, partners and licences, then fans out three workers two seconds apart that claim jobs atomically and reschedule themselves until there are none left. A migration_map table keyed on the Bubble id makes a rerun idempotent.
From the user’s side the move was a reactivation page: e-mail, a code, a WorkOS account, a new password, and then their profiles, posts and subscription where they had left them. The first days after the switch turned up what the import had silently dropped, three knowledge layers among them, and those were carried across through the same map. The Bubble address, my.uplinked.app, now serves the new app.
Partners and two currencies
Coaches and agencies bring clients in, and rather than a commission e-mail they get a workspace of their own: their clients, health scores, a contact log, and a method of up to five items per lane that is injected into their clients’ agents. They pre-buy licences at a discount and a client redeems one at Stripe checkout with a coupon for the full price. When the product went to the United States, Stripe Checkout turned out to honour only a coupon’s default currency, not its currency_options, so each of the seven licence durations has two coupons, one in euros and one in dollars, fourteen in all. Currency is an optional field on user, workspace and partner; unset means euros, which is how the existing data needed no migration. This is the part of the code with the deepest tests, run against Stripe’s test mode.
Two bugs users met
Dragging a post on the calendar called a mutation that changed planned_date_time and nothing else. The one-shot function Convex had scheduled for the old time still fired, so LinkedIn got the post at the time the user had just dragged it away from. Now reschedulePublishJob is the only way the date and the job may change, and they change together; every job carries the time it was created for, so a stale one skips instead of publishing; and a one-time repair function put right the records that were already wrong, then was removed.
Some users then found the app crashing with “removeChild is not a child of this node”. They had Google Translate switched on, which rewraps text nodes in its own font elements, and when React later tries to remove the original it is no longer where React left it. The fix patches removeChild and insertBefore on Node.prototype to return quietly when the parent does not match. It is not elegant. It is the only fix there is.
The type checker cannot be run on this codebase without crashing the machine, so the README tells coding agents in capitals never to, and the deploy gives it six gigabytes of heap. The landing page counts 750 entrepreneurs.