Zvessa docs

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 #

LayerSet byWhereApplies
Platform limitsthe platformits own settingsonly if the platform offers its own Claude key (off for now)
Space limitsthe space (owner or their AI)blueprint /ai/limitson 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 #

DefaultWhat
100messages one person may send per day, across every space on the platform's key
300messages one space may send per day
200runs a space's bots start on their own per day
6messages per person per minute, on any key
offtokens 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 #

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 blockwhat the viewer has left today, with a bar (props label, meter)
hq.aiUsage blockfor managers: today's use by person, the space and bots, against the limits, and whose key pays (prop limit)
get_ai_usage toolthe same report for MCP and the in-app AI (day = YYYY-MM-DD, default today); permission manage
api.kits.ailimits.limits.allowancequery: the signed-in person's allowance in the open space, with key (none, own or server), needsKey and canManage
api.kits.ailimits.limits.usagequery: 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.