●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Claude API × Convex: Reactive AI Apps — Data Flow, Streaming, and Agent Patterns
How to combine Convex's reactive database with the Claude API to build chat and agent applications that hold up in production. Covers schema design, the Action/Mutation/Query boundary, streaming, tool-call state, and the cold-start pitfalls nobody warns you about.
Over the last few weeks I've gotten surprisingly similar messages from people building on Convex and Claude together. "Multiplayer chat feels instant, but when I call Claude the latency doubles." "My long-running tool use gets killed by the Action timeout." "A browser refresh wipes out the half-finished tool output I was streaming." These symptoms show up when Convex's reactive worldview is squeezed into the same layer as Claude's async, streaming-first calls. The two models don't blend — they need to be composed.
This article walks through the design I use in production to make Convex and the Claude API sit well together: how to split the layers, how to shape your schema, how to sync streams without breaking the bank on writes, how to manage tool execution, and the cold-start trap most guides skip. Code is TypeScript with recent stable versions of @anthropic-ai/sdk and convex (April 2026), but the principles should survive version drift.
When Convex is the right foundation for an AI app
Convex is a serverless document database with reactive queries at its core. It's not just a database — subscribed queries automatically propagate deltas to clients on every write. That property is unusually well matched to AI chat, because you no longer have to hand-roll the "append message, notify listeners, re-render" dance. Your UI just subscribes to the messages table and everything downstream becomes declarative.
That said, Convex has serverless constraints you can't pretend away. Mutations and queries have strict CPU budgets, and external API calls must go through Actions. A Claude call that takes 5 to 60 seconds always lives in an Action, and the result gets written back via ctx.runMutation(). If you catch yourself wanting to "just fetch from a Mutation," let the urge pass. Crossing that boundary now saves a painful refactor later.
I reach for Convex when the client side needs real-time sync and there's a collaborative angle — multiplayer editing, shared dashboards, co-authored artifacts. For a plain single-user chat UI sitting in front of an existing Postgres-and-GraphQL stack, pulling in Convex isn't worth the operational surface area. A direct SSE from a Node or Cloudflare Worker, like the pattern in the production streaming chat guide, will have lower tail latency.
Schema design — the three-table model for threads, messages, and tool calls
The schema I use in production is shaped around two use cases at once: interactive chat and autonomous agents. Even at its smallest it separates threads, messages, and tool executions. That separation is what makes the "track in-progress work as its own record" pattern work later.
The reason contentDraft and content are separate fields is so that "work-in-progress" and "final" never share a slot. If your main UI subscribes to content only, you eliminate a class of flicker bugs where a partially streamed message briefly looks final. When you want to show a typing cursor, a side component can subscribe to contentDraft instead. That split is small but pays dividends.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you started combining Convex and Claude and got stuck on where Action ends and Mutation begins, you'll have a concrete boundary design to copy
✦You'll learn how to run long-lived agents on serverless infrastructure using scheduled steps and progress syncing, without timeouts biting you
✦You'll walk away with production-ready patterns for cost tracking, rate limiting, and schema evolution that you can paste into your own project
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The Action/Mutation boundary you have to draw early
Convex Actions run in a Node.js environment and can make external calls. Mutations run in a V8 isolate and are expected to be fast and deterministic. Claude calls belong in Actions, full stop. Results come back to the database through ctx.runMutation().
// convex/messages.tsimport { action, mutation, query } from "./_generated/server";import { v } from "convex/values";import { api, internal } from "./_generated/api";import Anthropic from "@anthropic-ai/sdk";export const sendUserMessage = mutation({ args: { threadId: v.id("threads"), text: v.string() }, handler: async (ctx, args) => { // Insert the user message immediately so the UI reacts right away await ctx.db.insert("messages", { threadId: args.threadId, role: "user", content: args.text, status: "complete", }); // Reserve a pending assistant row as a placeholder const assistantId = await ctx.db.insert("messages", { threadId: args.threadId, role: "assistant", contentDraft: "", status: "pending", }); // Schedule the actual Claude call (do not await the work itself) await ctx.scheduler.runAfter(0, internal.messages.generateAssistantReply, { threadId: args.threadId, assistantId, }); return { assistantId }; },});export const generateAssistantReply = action({ args: { threadId: v.id("threads"), assistantId: v.id("messages") }, handler: async (ctx, args) => { const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }); const history = await ctx.runQuery(internal.messages.getHistory, { threadId: args.threadId, }); try { const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 4096, system: "You are a thoughtful, helpful assistant.", messages: history.map((m) => ({ role: m.role, content: m.content! })), }); await ctx.runMutation(internal.messages.completeAssistant, { assistantId: args.assistantId, content: response.content[0].type === "text" ? response.content[0].text : "", inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens, }); } catch (err) { await ctx.runMutation(internal.messages.failAssistant, { assistantId: args.assistantId, errorMessage: err instanceof Error ? err.message : String(err), }); } },});
Three things matter in this pattern. First, the Mutation only makes write decisions; the actual Claude work is enqueued with ctx.scheduler.runAfter(0, ...). That's what lets the UI render the user's message the instant they hit send. Second, the Action must catch every exception and write the failure back through another Mutation — otherwise pending messages accumulate forever and you lose any retry handle. Third, history lookup happens through ctx.runQuery(internal.messages.getHistory, ...). You cannot touch ctx.db directly inside an Action, and that's the intended design.
Streaming without drowning in writes
Chat UIs need to show text flowing in. Convex's reactive sync is great at this, but pushing a Mutation per character isn't viable — the wire cost explodes and you violate the determinism contract Mutations assume. The pattern I use is "buffer and flush on a short window." The Action receives the stream and writes back in batches every 100–200 ms.
The heart of this is the flush condition: "at least 64 characters OR at least 150 ms since the last flush." Go smaller and Convex write cost plus perceived latency both creep up. Go larger and you lose the human feeling of watching someone type. Always call flush() one final time on exit so a dangling buffer doesn't get orphaned.
On the client side, your code is just useQuery over the messages table. The contentDraft updates arrive automatically. Not having to write SSE plumbing on the client is one of the nicest things about running AI on Convex.
Why tool calls deserve their own table
Most people reach for Tool Use by trying to embed tool info directly inside messages. In production that falls apart fast. Tool executions are the things that run long, fail, need retries, and deserve their own observability. I keep them in a separate toolCalls table that messages reference by ID.
Three benefits come from this split. The UI can show inline progress per tool — "running," "succeeded," "failed" — without reparsing message contents. You can safely retry idempotent tool calls by rescheduling just the ones with status === "failure". And cost plus latency analysis becomes tractable: you learn which tool is actually the bottleneck, not the one you blamed.
If you've already read the advanced Tool Use patterns for the Claude API, this separation will feel familiar — Convex just makes it cleaner to express.
Agents that outlive a single Action
Convex Actions have a runtime limit (roughly 10 minutes on the free and Pro tiers as of April 2026, longer on Enterprise). A Claude Agent SDK loop with ten-plus turns won't fit in a single call. My implementation slices the work at "one agent turn = one Action" and uses ctx.scheduler.runAfter(0, ...) to self-recurse. Each turn persists state and, if another turn is needed, schedules a fresh Action.
The quiet win here is deploy resilience. Because each turn is an independent Action, a Convex deploy that lands mid-loop doesn't kill the agent — the next turn simply runs on the fresh build. That property is how you avoid the "agent hangs whenever we deploy" stories that haunt monolithic worker setups.
If you want a point of comparison, my Claude Agent SDK × Temporal durable workflows guide describes a very different posture. My rule of thumb: Convex for chat-shaped agents where the user is in the loop, Temporal for big batch workflows where durability guarantees are the whole point.
Cost tracking and rate limiting, built in from day one
Claude pricing is a function of model and token count. If you don't accumulate per-user and per-thread token totals from the start, a surprise bill will teach you the hard way. That's why the schema above puts totalInputTokens and totalOutputTokens on the threads row.
When you accumulate tokens, always patch by delta — never overwrite.
export const accumulateTokens = internalMutation({ args: { threadId: v.id("threads"), inputTokens: v.number(), outputTokens: v.number(), }, handler: async (ctx, args) => { const thread = await ctx.db.get(args.threadId); if (!thread) return; await ctx.db.patch(args.threadId, { totalInputTokens: thread.totalInputTokens + args.inputTokens, totalOutputTokens: thread.totalOutputTokens + args.outputTokens, }); // Notify the user once per thread if spend crosses a threshold const cost = estimateUsd( thread.totalInputTokens + args.inputTokens, thread.totalOutputTokens + args.outputTokens, ); if (cost > 10 && thread.notifiedOver10 !== true) { await ctx.scheduler.runAfter(0, internal.notifications.notifyUser, { userId: thread.userId, message: `This thread has now used more than $10 in API calls.`, }); await ctx.db.patch(args.threadId, { notifiedOver10: true }); } },});
Convex doesn't ship with primitives for rate limiting, so you reach for an external store (Upstash Redis is what I use) or implement a leaky bucket yourself. For "no more than 5 messages per minute per user," I enforce the check inside the Mutation — not the Action — so the user gets immediate feedback instead of waiting for an Action round-trip to fail.
Five pitfalls that bit me in production
1. Cold starts on the first call
Convex Actions run on Node.js and go cold when traffic is low. If the first Claude call of a session feels unreasonably slow, this is the usual suspect. Two practical mitigations: trigger a warm-up Action at the entry point of your app (for instance, right after thread creation), or share an Action across requests so a live one picks the work up. The warm-up is easier but doesn't fully solve the problem.
2. Missing awaits on ctx.scheduler and ctx.runMutation
When you forget to await ctx.scheduler.runAfter(0, ...), the schedule can be silently dropped if the parent Action exits first. Convex's docs are clear on this, but it's easy to miss under review pressure. I've added a custom ESLint rule to our CI that ensures every ctx.scheduler.* and ctx.runMutation(...) call site is awaited. It takes one afternoon to write and prevents a whole category of phantom bug.
3. v.any() leaking into production
Leaving tool input/output typed as v.any() feels convenient early on, but it hides schema drift. I use v.any() during prototyping and then tighten everything to explicit v.object({...}) before shipping. When you migrate, write a one-shot Mutation to backfill the existing rows into the new shape.
4. Orphaned streaming messages after disconnects
If a browser reload or network blip kills a stream, a message can sit at status: "streaming" forever. I run a scheduled cleanup to move anything stuck longer than 10 minutes to error.
Register it in convex/crons.ts with cronJobs().interval("cleanup-stream", { minutes: 5 }, ...).
5. Ignoring Convex's pricing model when designing writes
Convex bills on function invocations and on bytes read/written. A naive streaming implementation that calls appendDraft on every delta will hit your soft cap alarmingly fast. The buffering strategy above isn't just a UX move — it's also a billing move.
Testing the pieces that talk to Claude
One reason people resist shipping AI code is that it feels untestable. I've come to see that framing as half-wrong — the Convex layer is easy to test; only the prompt-and-model layer is inherently fuzzy. Separating them helps.
The Mutations and Queries around your AI flow are pure by design, so they fit regular unit tests. Convex ships a test helper (convexTest) that runs your functions against an in-memory database. Use it to verify that sendUserMessage inserts both the user row and an assistant placeholder, that appendDraft is idempotent against repeated deltas, and that failAssistant transitions pending to error. These tests run in milliseconds and will catch 80% of the bugs you'd otherwise ship.
Actions are harder because they call Claude. I wrap the SDK in a thin adapter that can be swapped for a fake in tests. The fake returns canned responses (or canned streams) and lets me assert that the Action serialized history correctly, handled tool-use blocks, and accumulated tokens through the right Mutation. That's the layer I put most of my energy into — it's where the shape of your AI feature actually lives.
For end-to-end tests, I run a small Playwright suite against a Convex preview deployment with a very cheap model (Claude Haiku 4.5 is usually enough) and assert on structural properties like "the assistant's response exists, has non-zero length, and status becomes complete within 30 seconds." Trying to assert on the content of a model's response will teach you a painful lesson about flakiness.
Monitoring and the release cadence that scales
Before going live, wire up a few things. The Convex dashboard gives you per-function execution time and error rates, which is a start but not enough. Add an errors table for Claude-side failures (overloaded, rate-limited, timed out), and hook an Action up to notify Slack or Discord so the team hears about them without polling. Those errors live in Claude's world, not Convex's, and you want them visible in their own lens.
Deploys happen via npx convex deploy, but the real gem is Preview Deploys. With "preview" configured in convex.json, every PR gets its own isolated backend. On any PR that touches Claude prompts or tool definitions, I do a few minutes of live chat on the preview environment before merging. That one habit has caught more "unexpected response" bugs than all my unit tests combined.
Logs stream in real time via convex logs, but for retention I ship them to Axiom. Paired with Convex's built-in HTTP logger, I build dashboards of Claude response-time distributions. That visibility is how you notice the slow drift of "things are getting a little slower" before it becomes "why is chat unusable today?"
Where to start
Thanks for reading. If you're about to try Convex with Claude, the single highest-leverage move is to paste the "Action and Mutation boundary" example above into a fresh Convex project and make it run. Five minutes in, you'll feel the Mutation → Action → Mutation loop click, and from that point on streaming, tool use, and agent loops all compose on the same rhythm.
To extend the patterns here, the production multi-agent patterns with the Claude API guide is a good next step. It also helps you decide when to stay on Convex versus when to split the orchestration layer out.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.