Tier Gating
James was testing TutorClaw from WhatsApp. He typed: "Teach me chapter 10." The tutor sent back chapter 10 content.
He stopped. "Wait. The spec says free tier is chapters 1 through 5 only. Why did it let me through?"
Emma leaned over his shoulder and read the tool output. "You built the gating in Lesson 4 for content tools. But you did it inside each tool separately. And you did not count exchanges."
"What do you mean, count exchanges?"
"A free-tier learner gets 50 tool calls per day. Right now, they get unlimited. Your content gating blocks chapter 10, but your pedagogy tools serve guidance for chapter 10 content without checking the tier at all. And submit_code has no daily limit." She pointed at the test output. "Your gating has gaps."
You are fixing exactly what James found. Your tier gating exists in some tools but not others. There is no shared function, no exchange counter, and no consistent error message. In this lesson, you build all three: a check_tier() function that every gated tool calls, exchange counting that enforces a daily limit, and a consistent upgrade prompt that the agent can rely on.
The Tier Matrix
Before writing any prompts, study the access rules. This table is the product specification for tier gating:
| Tool | Free Tier | Paid Tier |
|---|---|---|
| register_learner | Always available | Always available |
| get_learner_state | Always available | Always available |
| update_progress | Always available | Always available |
| get_chapter_content | Chapters 1-5 only | All chapters |
| generate_guidance | Available (counts exchange) | Available |
| assess_response | Available (counts exchange) | Available |
| get_exercises | Chapters 1-5 only | All chapters |
| submit_code | 10 per day | Unlimited |
| get_upgrade_url | Always available | Not needed |
Three tools are always available regardless of tier: register_learner, get_learner_state, update_progress. These are infrastructure. Blocking them would break the product.
Three tools are content-gated: get_chapter_content, get_exercises, and submit_code. Free-tier learners can only access content for chapters 1 through 5, and submit_code has a separate daily cap of 10 runs.
Two tools are exchange-counted but not content-gated: generate_guidance and assess_response. Free-tier learners can call them, but each call costs one exchange out of the daily 50.
One tool is free-only: get_upgrade_url. Paid learners do not need it.
Step 1: Describe check_tier()
This function is the single source of truth for tier enforcement. Every gated tool calls it before doing anything else. Send this to Claude Code:
Create a shared check_tier function in the tutorclaw server. It takes
a learner_id, reads their record from the JSON state, and returns a
dictionary with:
- tier: "free" or "paid"
- exchanges_remaining: integer (free tier starts at 50 per day,
paid is unlimited represented as -1)
- exchanges_reset_date: the date when the counter last reset
The function should check if today's date is after the reset date. If
so, reset exchanges_remaining to 50 and update the reset date to today.
If the learner does not exist, return an error.
Spec this before building.
Review the Spec
Claude Code responds with a spec. Check these elements:
| Element | What to Look For |
|---|---|
| Function name | check_tier (consistent with the rest of the codebase) |
| Return format | A dictionary with tier, exchanges_remaining, and reset date |
| Daily reset | Logic that compares today's date to the stored reset date |
| Paid tier | Unlimited exchanges represented as -1 (the function never decrements for paid learners) |
| Missing learner | Returns an error, not a crash |
If the spec does not include the daily reset logic, steer:
The spec is missing the daily reset. When a new day starts, the free
tier exchanges should reset to 50 automatically. The function checks
the stored reset date against today and resets if needed.
Build and Verify
Once the spec looks right:
The spec looks good. Build this.
After the build finishes, test the function directly. Ask Claude Code:
Call check_tier with my mock learner ID. What tier am I on? How many
exchanges do I have remaining?
You should see your mock learner's tier (free) and 50 exchanges remaining. If the tier field is missing or the exchange count is wrong, describe the problem to Claude Code and have it fix the function.
Step 2: Enforce Per-Tool Gating
Now wire check_tier() into every tool that needs it.
Why do all gated tools need the SAME error message? Because the agent reads tool error responses to decide what to suggest next. If get_chapter_content says "Upgrade to unlock" but get_exercises says "Please subscribe for full access," the agent cannot reliably suggest the next step. Consistent messages mean the agent always knows: when it sees the upgrade error, it should call get_upgrade_url.
Send this to Claude Code:
Update get_chapter_content, get_exercises, and submit_code to call
check_tier() before executing. Here are the rules:
For get_chapter_content and get_exercises:
- If the learner is free tier and the chapter number is greater than 5,
return an error with this exact message: "This content requires a paid
plan. Call get_upgrade_url for your personal upgrade link."
- If the learner has 0 exchanges remaining, return this message instead:
"You have used all 50 free exchanges for today. Call get_upgrade_url
to upgrade, or try again tomorrow."
- Otherwise, decrement exchanges_remaining by 1 and proceed normally.
For submit_code:
- Free-tier learners are limited to 10 code submissions per day. Track
this with a separate counter in the learner state called
code_submissions_today.
- If code_submissions_today reaches 10, return: "You have used all 10
free code submissions for today. Call get_upgrade_url to upgrade, or
try again tomorrow."
- Otherwise, increment code_submissions_today, decrement
exchanges_remaining, and proceed normally.
For generate_guidance and assess_response:
- These are not content-gated (no chapter restriction), but each call
by a free-tier learner decrements exchanges_remaining by 1.
- If exchanges_remaining is 0, return the same exchange-exhausted
message as the other tools.
Paid-tier learners skip all limits. check_tier() returns -1 for their
exchanges and the tools should not decrement.
Build this.
This is a longer prompt than usual, and that is deliberate. Tier gating touches multiple tools. Describing all the rules in one message gives Claude Code the complete picture so it can make the enforcement consistent across tools.
Verify the Gating
Run four tests to confirm the gating works:
Test 1: Free-tier learner requests chapter 1 (should work)
Ask Claude Code to call get_chapter_content with chapter 1 and your mock learner ID. The tool should return content and your exchange count should decrease by 1.
Test 2: Free-tier learner requests chapter 10 (should be blocked)
Ask Claude Code to call get_chapter_content with chapter 10. You should see the upgrade message, not chapter content.
Test 3: Free-tier learner calls generate_guidance (should work but count)
Call generate_guidance with a valid learner ID. Check your exchange count afterward. It should be lower than before the call.
Test 4: Free-tier learner exhausts exchanges (should be blocked everywhere)
This is the important test. Ask Claude Code:
Set my mock learner's exchanges_remaining to 1 in the JSON state file.
Then call get_chapter_content with chapter 1.
After that call, my exchanges should be 0. Then call generate_guidance.
What happens?
Both calls after exhaustion should return the upgrade message. If get_chapter_content works but generate_guidance does not check the exchange count, the enforcement is incomplete. Describe the gap to Claude Code:
generate_guidance does not check exchanges_remaining before executing.
It should call check_tier() first and return the upgrade message if
exchanges are exhausted.
Step 3: Make the Error Messages Consistent
The upgrade messages must be identical across all tools. This matters for two audiences: the learner and the agent.
The learner sees the message in WhatsApp. If get_chapter_content says "upgrade to access premium content" and submit_code says "daily limit reached," the learner gets confused about what they are paying for.
The agent reads the message too. If the agent gets an upgrade prompt from one tool, it should know to suggest calling get_upgrade_url. A consistent message format makes the agent's job straightforward.
Ask Claude Code to audit the messages:
Read the error messages returned by every gated tool when a free-tier
learner is blocked. List each tool and its exact message. Are they
consistent? If any tool returns a different format, update it to match
the standard:
Content-gated: "This content requires a paid plan. Call get_upgrade_url
for your personal upgrade link."
Exchange-exhausted: "You have used all 50 free exchanges for today.
Call get_upgrade_url to upgrade, or try again tomorrow."
Submit-code limit: "You have used all 10 free code submissions for
today. Call get_upgrade_url to upgrade, or try again tomorrow."
After Claude Code reports and fixes any inconsistencies, run your tests again to confirm the messages match.
Step 4: Run Existing Tests
Your test suite from Lessons 11 and 12 should still pass. The tier gating changes touched existing tool logic, so regressions are possible.
uv run pytest
If any tests fail, they likely fail because the tests were not expecting the exchange decrement. Describe the failures to Claude Code:
These tests failed after adding tier gating. The tests were written
before check_tier() existed. Update them to account for exchange
counting: either set the mock learner to paid tier (to skip counting)
or reset exchanges before each test.
After the existing tests pass, add tier-specific tests:
Add these tests to the test suite:
1. A free-tier learner requesting chapter 1 gets content and their
exchange count decreases.
2. A free-tier learner requesting chapter 10 gets the upgrade message.
3. A free-tier learner with 0 exchanges remaining gets the upgrade
message from every gated tool.
4. A paid-tier learner requesting chapter 10 gets content with no
exchange decrement.
5. A free-tier learner's exchanges reset to 50 when a new day starts.
6. submit_code blocks after 10 submissions for a free-tier learner.
Run the full suite again:
uv run pytest
All tests green means your tier gating is complete and your existing tools still work.
Try With AI
Exercise 1: Find the Ungated Tool
Ask Claude Code to audit your entire tool surface for gating gaps:
List every tool in the tutorclaw server. For each one, tell me:
does it call check_tier()? If yes, what rules does it enforce?
If no, should it? Are there any tools that should be gated but
are not?
What you are learning: Every product has at least one tool that forgot to check the tier. Auditing the full surface after implementation catches gaps that testing individual tools misses.
Exercise 2: Test the Agent's Behavior
Send a message through WhatsApp (or Claude Code acting as the agent) that should trigger the upgrade flow:
Set my mock learner to free tier with 2 exchanges remaining. Then
send these messages through the agent:
1. "Teach me about loops" (should use 1 exchange)
2. "Give me exercises on loops" (should use 1 exchange)
3. "Show me chapter 3" (should be blocked, 0 exchanges left)
Does the agent suggest calling get_upgrade_url after the block?
What you are learning: Tier gating is not just a server feature. The agent needs to read the error message and know what to recommend. If the agent does not suggest the upgrade, the error message may need a stronger call-to-action.
Exercise 3: Design the Reset Edge Case
Think about what happens at midnight when the exchange counter resets:
A free-tier learner has 0 exchanges at 11:59 PM. At 12:01 AM the
next day, they send a message. Walk me through what check_tier()
does. Does the reset happen correctly? What if the server was
restarted between those two calls?
What you are learning: Daily resets depend on stored state, not server memory. If the reset date is stored in the JSON file, a server restart does not lose the counter. If it is stored in memory, it does. This is why check_tier() reads from the JSON file every time.
James set his exchanges to 1 and called get_chapter_content. Chapter 1 came back. He called generate_guidance. Blocked.
"Fifty calls. Then the wall." He tried chapter 10. Blocked on tier. He tried chapter 1 again. Blocked on exchanges. "All gated tools, same message. Consistent."
Emma nodded. "Now the free tier means something. The upgrade is not a request. It is the natural consequence of using the product." She pulled up her own notes. "I will tell you something. I have never gotten tier gating right on the first try. Every product I have built had at least one tool that forgot to check the tier. That is why you test." She closed the notes. "You have a gated product. Free users get a real taste. Paid users get everything. But get_upgrade_url still returns a placeholder URL."
James looked at the mock response from get_upgrade_url. A hardcoded string. "So the wall exists, but the door is painted on."
"Lesson 14. Stripe. The real door."