
The Story of How an Autonomous AI Agent Stopped Due to Anthropic OAuth Token Ban, and How We Recovered Using Frontmatter Prompt Control + Claude Code Delegation
This page has been translated by machine translation. View original
Introduction
My self-built autonomous AI agent "Ruby" suddenly stopped responding on Discord one day. The typing indicator would appear, but no reply would come. Upon investigating the cause, I discovered that Anthropic had banned the use of OAuth tokens (sk-ant-oat) for third-party API calls in February 2026.
This article covers the journey from discovering this issue to resolving it, and also documents the prompt size optimization and Claude Code delegation architecture implementation that this incident prompted me to undertake.
Prerequisites & Environment
- Ruby: Autonomous AI agent (Python 3.12+, Discord/LINE/Slack compatible)
- Runtime: Oracle Cloud VM (Docker container)
- LLM Backend: Anthropic Claude API (via litellm)
- Claude Code CLI: Still usable with OAuth tokens (Max subscription)
- Deployment: git push → GitHub Actions → SSH → Docker rebuild
Problem 1: OAuth Token Ban
Symptoms
When sending a message on Discord, Ruby's typing indicator ("...") would appear and then simply disappear. No error message would come back.
Checking the container logs:
litellm.BadRequestError: AnthropicException - {"type":"error","error":{"type":"invalid_request_error","message":"Error"}}
Cause
In February 2026, Anthropic banned the use of OAuth tokens starting with sk-ant-oat for third-party API calls other than the Claude Code CLI. This was a countermeasure against open-source CLI tools that were freely using the API via OAuth tokens from Claude Max subscriptions.
In other words:
- Claude Code CLI → Still usable with OAuth tokens (as an official tool)
- Direct API calls via litellm / SDK / curl / etc. → OAuth tokens cannot be used. A console API key (
sk-ant-api) is required
Resolution
I issued an API key from console.anthropic.com, registered it as ANTHROPIC_API_KEY in GitHub Secrets, and added it to the .env build in the deploy workflow.
Problem 2: Rate Limit Hit
After switching to the API key, a new problem emerged.
rate_limit_error: This request would exceed your organization's rate limit of 30,000 input tokens per minute
Ruby's system prompt + tool schema alone amounted to approximately 16,000 tokens. Adding user messages + conversation history, a single API call consumed around 25,000 tokens. On top of that, when the Initiative engine (autonomous actions) fired simultaneously, exceeding 50,000 tokens per minute was a certainty.
Prompt Breakdown
| Component | Token Count |
|---|---|
| System prompt (personality & rules) | ~6,050 |
| Tool schema (10 tools) | ~4,500 |
| Conversation history | ~6,000-15,000 |
| Total | ~16,000-25,000 |
The core of the problem: casual chat on Discord and complex self-modification sessions in DMs were both using the exact same prompt.
Solution: Three Pillars
Pillar 1: Frontmatter-Based Dynamic Prompt Loading
Concept
Declare loading conditions in YAML Frontmatter within personality/rule files (.md), and load only the files needed based on the channel context.
---
channels: [discord]
priority: 20
---
# Discord Rules (HARD RULES!)
- **NO markdown tables** — they render as ugly monospace text
- **Use `table-image` skill** — render tables as images
- **Wrap links in `<>`** to suppress embeds
---
channels: [dm, system]
priority: 30
---
# Reincarnation — How You Evolve Your Own Code
...
Implementation
The originally massive 352-line rules/AGENTS.md was split into 6 specialized files:
| File | channels | Content |
|---|---|---|
rules/CORE.md |
All channels | Safety rules, skill confirmation |
rules/DISCORD.md |
discord | Formatting, table prohibition |
rules/GROUP_CHAT.md |
discord, slack | Speech judgment, reactions |
rules/SELF_MODIFY.md |
dm, system | Self-modification rules |
rules/MEMORY_RULES.md |
dm, system | Memory management |
rules/SYNC.md |
dm, system | Sync system |
Key parts of the personality file loader:
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
_CHANNELS_RE = re.compile(r"channels:\s*\[([^\]]*)\]")
_PRIORITY_RE = re.compile(r"priority:\s*(\d+)")
def parse_frontmatter(text: str) -> tuple[str, list[str], int]:
match = _FRONTMATTER_RE.match(text)
if not match:
return text, [], 50 # No frontmatter = always load (backward compatible)
fm_block = match.group(1)
body = text[match.end():]
# Parse channels and priority...
return body.strip(), channels, priority
Channel-Based Filtering
def get_prompt_sections(self, channel: str = "dm", include_memory: bool = True) -> list[str]:
sections = []
for pf in sorted(self.files, key=lambda f: f.priority):
if pf.channels and channel not in pf.channels:
continue
sections.append(pf.body)
if include_memory and self.memory:
sections.append(self.memory)
return sections
Reduction Results
| Context | Personality Tokens (Before) | Personality Tokens (After) | Reduction |
|---|---|---|---|
| Discord channel | ~6,050 | ~1,500 | -75% |
| DM | ~6,050 | ~4,200 | -30% |
| System events | ~6,050 | ~3,800 | -37% |
| Initiative | ~6,050 | ~750 | -88% |
Additionally, tool schemas are filtered in the same way:
TOOL_SETS = {
"chat": {"response", "web_search", "skill", "memory_search",
"discord_history", "delegate"},
"dm": None, # All tools
"system": {"response", "shell", "memory_save", "memory_search",
"daily_log", "message", "skill", "self_modify", "delegate"},
"initiative": {"response", "memory_search", "message",
"daily_log", "delegate"},
}
Pillar 2: Prompt Caching
Anthropic automatically caches identical system prompt prefixes across consecutive calls. Cache reads cost 0.1x the price.
The problem was that datetime.now() was included in the system prompt, causing the cache to be invalidated every time.
Solution: Separate the prompt into a stable prefix and a volatile suffix.
system_message = {
"role": "system",
"content": [
{"type": "text", "text": stable_prefix,
"cache_control": {"type": "ephemeral"}},
{"type": "text", "text": volatile_suffix},
],
}
- Stable prefix: Personality, rules, daily log (changes at most once per day) → Cache target
- Volatile suffix: Timestamps, instance name, extended sections → Small, changes every time
Pillar 3: Haiku Dispatcher + Claude Code Delegation
This is the most significant architectural change.
Core Concept
Haiku doesn't think. It only sorts. Thinking is done by Claude Code (Opus).

- Haiku ($1/M input tokens): Dispatcher for all chats
- Claude Code CLI (OAuth, free with Max subscription): Complex reasoning, research, analysis
Implementation of the delegate Tool
Reusing the existing run_code_agent() infrastructure:
@register
class DelegateTool(Tool):
name = "delegate"
description = (
"Delegate a task to Claude Code (Opus) for deep reasoning. "
"Use for ANY task requiring real thinking."
)
async def execute(self) -> ToolResponse:
from rubyclaw.evolution.code_agent import resolve_config, run_code_agent
prompt = (
"You are assisting Ruby, an AI agent. "
"Do NOT modify any files. Just research and respond.\n\n"
f"Task: {task}"
)
# Send progress message
send_callback = self.context.data.get("send_callback")
if send_callback:
await send_callback("Let me think about this more carefully...")
success, output = await run_code_agent(
prompt=prompt,
project_root=project_root,
config=agent_config,
phase="plan", # Read-only mode
)
return ToolResponse(message=output)
Key points:
- Reuses existing infrastructure from the
self_modifytool (run_code_agent()) phase="plan"for read-only mode (no file modifications)- Progress message notifies the user that it's "thinking..."
Instructions in System Prompt
## Delegation — Your Most Important Tool
You are running on a lightweight model optimized for speed and cost.
You are a dispatcher, not a thinker.
For ANY task requiring real reasoning, use the `delegate` tool.
**Always delegate:**
- Complex questions, research, analysis
- All scheduled/cron tasks
- Strategic decisions, code review
**Respond directly (no delegation):**
- Simple greetings ("hi", "thanks")
- Acknowledgments ("ok", "got it")
- Relaying delegate results to the user
Results & Verification
Test 1: Simple Question (No Delegation)
Q: What is the capital of the country that hosted the 2024 Summer Olympics?
A: Paris — France hosted the 2024 Olympics, and Paris is both the capital and the host city. (Though honestly, this one was simple enough I could've just answered it directly. 💎)
Haiku responded directly. The delegation judgment was also appropriate.
Test 2: Complex Codebase Analysis (With Delegation)
Q: Analyze the rubyclaw codebase: how many tools are registered, what are their names, and which ones are in the chat tool set vs full DM set?
A: (via delegate tool) 12 tools total, split across contexts: Chat: 6 tools, DM: All 12, System: 9 tools, Initiative: 5 tools...
Confirmed in logs:
tool:delegate args={'task': 'Analyze the rubyclaw codebase...'}
Delegation completed (1735 chars)
Delegation latency was approximately 44 seconds (Claude Code CLI startup + Opus processing).
Cost Comparison
| Scenario | Before (Sonnet) | After (Haiku + Delegation) |
|---|---|---|
| Simple chat (70%) | ~$0.048/msg | ~$0.005/msg |
| Complex questions (20%) | ~$0.048/msg | Free (Claude Code) |
| System/cron (10%) | ~$0.048/msg | ~$0.003/msg |
Summary
- Anthropic's OAuth token ban is difficult to detect because the error message is unhelpful.
invalid_request_error+"Error"alone gives no indication of the cause - Frontmatter-based prompt control is more extensible than hardcoded profiles. You simply add a new rule file without needing to change code
- The Haiku dispatcher + Claude Code delegation pattern can significantly reduce API costs while maintaining Opus-quality output for complex tasks
- By leveraging the "loophole" that Claude Code CLI can still use OAuth, the value of a Max subscription can be maximized
