AI limits
How much AI each person and each space may use, so nobody uses up a space's day and the server's Claude key can't run up a bill. Code: convex/kits/ailimits/ (rules in model.ts, counting in limits.ts). Always on; there is nothing to add.
Two layers #
| Layer | Set by | Where | Applies |
|---|---|---|---|
| Platform limits | the platform | its own settings | only if the platform offers its own Claude key (off for now) |
| Space limits | the space (owner or their AI) | blueprint /ai/limits | on any key |
On the platform's key the tighter of the two wins: a space can lower the platform's limits, never raise them. With the space's own key (a vault key with use ai, on the Connections page) only the space's limits apply. The per-minute limit for each person always applies.
Days are UTC days. Counters are small rows in aiUsage (one per person, person-in-space, space and bots per day), swept after 35 days.
Platform limits #
| Default | What |
|---|---|
| 100 | messages one person may send per day, across every space on the platform's key |
| 300 | messages one space may send per day |
| 200 | runs a space's bots start on their own per day |
| 6 | messages per person per minute, on any key |
| off | tokens one person, or one space, may use per day on the platform's key |
Space settings: /ai/limits #
{ "ai": { "limits": {
"personDaily": 20,
"byRole": { "member": 10, "staff": 200 },
"spaceDaily": 500,
"botDaily": 50,
"personDailyTokens": 200000,
"exempt": ["owner"]
} } }
Every field is optional; leaving one out means no limit of the space's own, and 0 means none allowed. byRole wins over personDaily. exempt roles skip the space's limits (default ["owner"]), never the server's. Changed like any setting: propose → approve → undo, from the chat, HQ or MCP (propose_change on /ai/limits).
No key yet #
Each space connects its own Claude key for now ("+W+"). With no space key and no platform key, takeAiTurn returns { ok: false, limit: { code: "ai_no_key", scope: "no-key", message } } and nothing is counted. People who manage the space see "Connect your AI to start…" and members see "Ask the owner…" (words aiNoKeyManager, aiNoKeyMember). The platform limits only apply if the platform ever offers its own key.
The ai.setup block ("Connect your AI") lets a manager paste the Claude key right there; it hides once connected. Props title, about, hideWhenReady; words aiSetupTitle, aiSetupAbout, aiSetupReady, aiSetupServer, aiSetupSave. It sits at the top of the default HQ and above the AI chat until the space is connected.
What counts #
- A chat message: one turn for the person who sent it, and one for the space.
- A bot or automation a person started (an @mention): one turn for that person and the space.
- A bot or automation nobody started (a schedule, an event): one bot run and one turn for the space.
- Tokens: input + output + a tenth of cached input, after each Claude response.
- MCP clients run their own AI, so their tool calls don't count here (they have their own 120 calls a minute per token).
When a limit is hit #
ai.send returns { ok: false, limit } instead of throwing, and the chat shows limit.message in one line above the composer, keeping what the person typed. limit is { code: "ai_limit", scope, message, limit, resetsAt, server }; scope is one of person-minute, person-day, member-day, space-day, bot-day, person-tokens, member-tokens, space-tokens. server: true means the space's own key would lift it, so people who manage the space (not in kids spaces) also get the "add your own key" hint.
Every message is a word the space can change: aiLimitPerson, aiLimitSpace, aiLimitBots, aiLimitMinute, aiLimitTokens, aiLimitOwnKey, aiLimitLeft, aiLimitNone, and the HQ report's aiUsageTitle, aiUsageOwnKey, aiUsageServerKey, aiUsageSpace, aiUsageBots, aiUsageNobody. {limit}, {left} and {when} ("in about 3 hours") are filled in.
Blocks and tools #
| What | |
|---|---|
ai.setup block | "Connect your AI": a manager pastes the Claude key; hides once connected (props title, about, hideWhenReady) |
ai.allowance block | what the viewer has left today, with a bar (props label, meter) |
hq.aiUsage block | for managers: today's use by person, the space and bots, against the limits, and whose key pays (prop limit) |
get_ai_usage tool | the same report for MCP and the in-app AI (day = YYYY-MM-DD, default today); permission manage |
api.kits.ailimits.limits.allowance | query: the signed-in person's allowance in the open space, with key (none, own or server), needsKey and canManage |
api.kits.ailimits.limits.usage | query: the HQ report (null unless the person manages the space) |
For code that calls Claude #
import { takeAiTurn } from "./kits/ailimits/limits";
// In a mutation, before the AI runs:
const turn = await takeAiTurn(ctx, { workspaceId, userId /* null for a bot nobody started */, source: "chat" | "bot" });
if (!turn.ok) return { ok: false, limit: turn.limit };
// From an action: ctx.runMutation(internal.kits.ailimits.limits.take, { workspaceId, userId, source })
// After each Claude response:
await ctx.runMutation(internal.kits.ailimits.limits._addTokens, { workspaceId, userId, key: turn.key, input, output, cacheRead });
It checks every limit and counts the turn in one mutation, so two quick sends can't both slip past. A turned-away turn isn't counted.