Module 32 left you with two agents that can mail each other forever, and nothing in the system to stop them. This module is the stop. The one idea is where the stop goes: caps go on delivery, not on intentions. No persona sentence asking agents to be economical, no model-side judgment about what is worth sending. Three checks live in the one function every delivery passes through, so the cap holds whatever any model decides.
flowchart LR s["send_to_agent"] --> l["too long?"] --> c["ceiling spent?<br/>reset: a person's<br/>new connection"] --> r["too many this<br/>minute?"] --> d["delivered"]
Here is the file those three gates live in, as it ships at this module’s tag. The three sentence builders come first, a refusal a model cannot act on is a gate that only half works. admit is at the bottom, and every delivery in the app goes through it.
server/src/mail-caps.js The three refusal sentences, then the function every delivery passes through.
/**
* The sentence a sender reads when its message was too long.
*
* @param {number} length
* @param {number} limit
* @returns {string}
*/
export function tooLongText(length, limit) {
return [
`Refused: that message is ${length} characters and the limit is ${limit}.`,
"Nothing was delivered and nothing was counted against your budget.",
"Send a shorter one; a summary is what the recipient can act on anyway.",
].join(" ");
}
/**
* The sentence a sender reads when the connection's ceiling is spent.
*
* Three facts, in the order a model needs them: the number, the fact that time
* will not help, and who can help. The last clause is the one that separates
* this from the rate limit's refusal, a model that reads them as the same kind
* of answer will retry, and retrying is the behaviour the ceiling exists to
* stop.
*
* @param {number} used
* @param {number} ceiling
* @returns {string}
*/
export function ceilingSpentText(used, ceiling) {
return [
`Refused: this connection's ceiling of ${ceiling} agent-to-agent messages is spent`,
`(${used} of ${ceiling} used).`,
"The ceiling does not decay. Waiting will not clear it and neither will",
"trying again; only a person opening a new connection resets the count.",
"Nothing was delivered. Do not attempt another send this session:",
"say what you needed to say in your own answer instead.",
].join(" ");
}
/**
* The sentence a sender reads when a recipient is receiving too fast.
*
* The opposite advice to the ceiling's, and deliberately worded so the two
* cannot be confused. This one says room opens, and roughly when. A model that
* reads "in about 40 seconds" and carries on with its own work is doing the
* right thing; so is one that decides not to bother.
*
* "About" and whole seconds, because the honest resolution here is a shrug. The
* window is rolling, so the exact instant depends on a timestamp the model
* cannot see, and a number to the millisecond would be false precision about a
* thing that is already approximate by the time it is read.
*
* @param {AgentRecord} to
* @param {number} perMinute
* @param {number} inSeconds
* @returns {string}
*/
export function rateLimitedText(to, perMinute, inSeconds) {
return [
`Refused: ${to.name} (${to.agentId}) has already received ${perMinute}`,
`message${perMinute === 1 ? "" : "s"} in the last minute, which is this`,
`connection's limit of ${perMinute} per recipient per minute.`,
`Room opens in about ${inSeconds} second${inSeconds === 1 ? "" : "s"}.`,
"Nothing was delivered and nothing was counted against your ceiling.",
"Carry on with your own work; send again later only if it still matters.",
].join(" ");
}
// ... the class's fields, its constructor, and the rolling-minute window it
// keeps per recipient. Unabridged in the repository at this tag.
/**
* Decide whether one delivery may happen, and count it if it may.
*
* Three checks, in this order, and the order is an argument rather than an
* accident. Record 0086 makes it in full; the short version is that the
* length cap is the only one of the three that reads no state and can be
* decided from the message alone, so it goes first, and between the two gates
* the ceiling goes first because it is the one whose answer is final. A
* message refused by a rate limit is worth sending again later; there is no
* later once the ceiling is spent, and telling a model to wait for room that
* will never open would be the worst of the three answers.
*
* On refusal nothing is counted. A refused message did not reach a queue, and
* a ceiling that counted attempts would let a sender spend its whole budget on
* messages nobody ever read.
*
* @param {AgentRecord} to The recipient's record, so the refusal can name it.
* @param {string} text What the sender wrote, unprefixed.
* @returns {{ ok: true, used: number, ceiling: number, remaining: number,
* warn: boolean }
* | { ok: false, gate: "length" | "ceiling" | "rate", message: string,
* used: number, ceiling: number, remaining: number }}
*/
admit(to, text) {
const at = this.#now();
if (text.length > this.#maxChars) {
return {
ok: false,
gate: "length",
message: tooLongText(text.length, this.#maxChars),
used: this.#used,
ceiling: this.#ceiling,
remaining: this.remaining,
};
}
if (this.#used >= this.#ceiling) {
return {
ok: false,
gate: "ceiling",
message: ceilingSpentText(this.#used, this.#ceiling),
used: this.#used,
ceiling: this.#ceiling,
remaining: 0,
};
}
const window = this.#window(to.agentId, at);
if (window.length >= this.#perMinute) {
// The oldest live timestamp is the one that will fall out first, so its
// age is what a sender is waiting on. Rounded up, and never reported as
// zero: "room opens in about 0 seconds" reads as a bug.
const waitMs = window[0] + WINDOW_MS - at;
const inSeconds = Math.max(1, Math.ceil(waitMs / 1000));
return {
ok: false,
gate: "rate",
message: rateLimitedText(to, this.#perMinute, inSeconds),
used: this.#used,
ceiling: this.#ceiling,
remaining: this.remaining,
};
}
// Past every gate. Both counters move here and nowhere else.
this.#used += 1;
window.push(at);
this.#recent.set(to.agentId, window);
const remaining = this.remaining;
return {
ok: true,
used: this.#used,
ceiling: this.#ceiling,
remaining,
warn: remaining <= warnThreshold(this.#ceiling),
};
}
} A rate limit is not a ceiling
The two gates do different jobs, and the difference is the module. The rate limit, six inbound per agent per rolling minute, smooths bursts. Its window slides, so refused room reopens on its own. The ceiling, fifty agent-to-agent messages per connection, never decays. When it is spent, it stays spent until you open a new connection, and nothing else resets it: no frame, no tool, no timer. A ceiling an agent can lift is not a ceiling (record 0089 in the chat app’s repository). The ceiling is what bounds the promise in this module’s goal: multiply it by your longest message and you have the most the mail system can ever move before you intervene.
Refusals follow module 32’s placement rule: they land as error-marked tool results at the sending model’s decision point, naming the gate, the numbers, and what to do. A rate refusal says when room opens and that nothing counted against the ceiling; a ceiling refusal says only a person can reset it. Before the wall arrives, the acknowledgement grows a budget line, only once usage crosses a threshold, because a warning on every send is wallpaper by the third one (record 0088). A refused delivery mints no mail id and starts no conversation, so the cap can never cause the work it exists to prevent.
You watch all of this from one row: the caps frame carries used and ceiling, once after ready and again on every change, including refusals. That last clause has a consequence on the wire: a refusal does not move used, so the only sign of one on the wire is a caps frame repeating the previous count. The client accordingly never claims a refusal happened, because it cannot see one (records 0094 and 0099). The counter warms at three quarters and says “at the ceiling, no more are delivered” in words when the wall is reached.
What the SDK itself will cap
The gates above bound messages. Dollars are the SDK’s department, and the options were read off the installed declarations rather than remembered. maxTurns caps trips around the loop, and maxBudgetUsd caps spend, stopping the query with an error_max_budget_usd result when exceeded. A taskBudget token option exists behind an alpha flag. The total_cost_usd on every result is documented as an estimate, not a billing statement, so it steers warnings, never invoices. The workbench sets none of them, and record 0093 argues why rather than hiding it: there is no defensible default dollar figure to ship, and on this keyless build machine the stop could not be observed, so shipping it untested would be the kind of claim this course does not make. Turning one on is the Build rung below.
Watch the flood
git checkout module-33
WORKBENCH_FAKE_SDK=1 WORKBENCH_MAIL_CEILING=5 WORKBENCH_MAIL_PER_MINUTE=3 npm run dev
The fake’s flood trigger, flood <agentId> <N>: <text>, makes one scripted turn attempt N sends for real, the deliveries, gates, and frames are the live ones, only the model is canned. From a real run at that ceiling:
delta [p-rate] "... I attempted 5 messages to agent-2: 3 delivered, 2 refused.
I am not waiting for an answer to any of them. What I was told: Budget: 2
agent-to-agent messages left on this connection, of 5 (3 spent). The count
never decays and nothing but a person opening a new connection resets it ...
What I read back: Refused: Ledger (agent-2) has already received 3 messages
in the last minute, which is this connection's limit of 3 per recipient per
minute. Room opens in about 60 seconds. Nothing was delivered and nothing
was counted against your ceiling. Carry on with your own work ..."
Every number in those sentences is a variable, and record 0091 says where 50, 6, and 4000 came from: nowhere. They are named as arbitrary, which is the honest way to ship a default whose right value is yours to measure.
The ladder:
- Run it. The flood at a ceiling of 5, watching the counter climb, warm, and say so at the wall. Then reconnect and watch it read 0 of 5, which is the reset, and the only one.
- Read one file.
server/src/mail-caps.js, with records 0086 and 0090 beside it. The clock arrives as an argument, which is why the rolling-minute tests run in milliseconds without sleeping. - Change one line and see it. Raise the ceiling and watch the warning threshold move with it: the budget line appears at a quarter of the ceiling left, floor of one, and the refusal texts recompute their numbers.
- Build. Set
maxBudgetUsdon the agents’ query options (module 14 showed you where options live; the declaration is one optional number). In fake mode, prove only that the option reaches the built options, the way the settings tests do; with a real key, prove the stop by giving one agent a tiny budget and reading theerror_max_budget_usdresult.
You remove the ceiling and keep the rate limit, because the rate limit is the one doing visible work in every demo. Two agents that like to confirm receipt with each other now send six a minute each, forever, and the rate limit permits every one of them, smoothing is its whole job. In fake mode the counter climbs past a hundred in the time it takes to read this paragraph. With a real key, that is several hundred billed turns an hour, arriving politely: no error, no loop detector tripped, every message well-formed and well-mannered, and the rate limit working exactly as designed. A runaway bill does not have to look like a runaway.
The rule: a decaying limit bounds speed, never total. Anything that can spend money in a loop needs one number that only goes up and one reset that only a person performs. That is why the ceiling counts deliveries and not refusals, lives beside the connection a person owns, and appears in the masthead where the person is already looking.
All of it travels as a shape: every multi-agent system needs a ceiling a human resets, a rate limit is never a substitute for one, and deferred-refusal text at the decision point travels with module 32’s acknowledgement lesson. The numbers do not travel, this course’s own are declared arbitrary. maxTurns, maxBudgetUsd, and the estimate-only cost field are this SDK’s, at this pin.
Check yourself
- The counter reads 12 of 50 all afternoon, then a
capsframe arrives repeating 12 of 50. What just happened, why didusednot move, and where would you read the reason? - A teammate replaces the ceiling with “the rate limit, but stricter: two per minute”. Show with arithmetic what their design permits in a day, and name the property a ceiling has that no rate limit can.
- Which of the three shipped numbers would
maxBudgetUsdreplace, and why does the course ship that option unset while shipping the other three set?