The agent core and prompt strategies
How `claude_skills_agent.py` configures `claude-agent-sdk`, steers each message at the skill, and reads tokens and cost.
claude_skills_agent.py is the host-agnostic core. Both surfaces use it unchanged. It holds four module constants, all overridable at deploy time without a rebuild:
| Constant | Env var | Default |
|---|---|---|
SKILL_PARENT_DIR | CLAUDE_AGENT_CWD | /app |
DEFAULT_MODEL | CLAUDE_AGENT_MODEL | claude-sonnet-4-6 |
MAX_TURNS | CLAUDE_AGENT_MAX_TURNS | 50 |
SKILL_NAME | none (filled from {{SKILL_NAME}}) | the skill's name: |
create_agent() runs once at startup. It confirms claude-agent-sdk imports, raises if neither ANTHROPIC_API_KEY nor CLAUDE_CODE_USE_FOUNDRY=1 is set, and logs a warning if SKILL.md is missing from <cwd>/.claude/skills/<skill-name>/. In that case the agent still starts, but skill loading fails.
The SDK options
base_tools = ["Bash", "Read", "Write", "Glob", "Grep", "Skill"]
kwargs: Dict[str, Any] = dict(
model=DEFAULT_MODEL,
cwd=SKILL_PARENT_DIR, # /app, where .claude/skills/ lives
tools=base_tools, # the tools DEFINED in the request
allowed_tools=base_tools, # the tools PERMITTED to run
setting_sources=["project"], # load skills from <cwd>/.claude/skills/
system_prompt={"type": "preset", "preset": "claude_code"},
permission_mode="dontAsk", # deny outside the allowlist, never prompt
max_turns=MAX_TURNS,
env={
"DISABLE_AUTOUPDATER": "1",
"CLAUDE_CODE_MAX_RETRIES": os.getenv("CLAUDE_CODE_MAX_RETRIES", "5"),
"REPORT_OUTPUTS_DIR": OUTPUTS_DIR, # from {{SKILL_ENV_VARS}}
},
)
budget = (os.getenv("CLAUDE_AGENT_MAX_BUDGET_USD") or "").strip()
if budget:
kwargs["max_budget_usd"] = float(budget)
effort = (os.getenv("CLAUDE_AGENT_EFFORT") or "").strip().lower()
if effort in ("low", "medium", "high", "xhigh", "max"):
kwargs["effort"] = effort
buffer_bytes = (os.getenv("CLAUDE_AGENT_MAX_BUFFER_BYTES") or "").strip()
if buffer_bytes:
kwargs["max_buffer_size"] = int(buffer_bytes)
options = ClaudeAgentOptions(**kwargs)| Option | Why it is set this way |
|---|---|
tools= | Set explicitly. Unset, the whole Claude Code toolset is defined in every prompt, on the order of 16k prompt tokens. allowed_tools alone does not prevent that. |
allowed_tools= | Must include "Skill", or the SDK cannot route to the skill. |
setting_sources=["project"] | Makes the SDK discover /app/.claude/skills/<skill-name>/. |
permission_mode="dontAsk" | Denies anything outside the allowlist instead of prompting. A permission prompt in an unattended session is a hang. Do not use "bypassPermissions": the bundled CLI refuses it as root. |
DISABLE_AUTOUPDATER=1 | A self-updating CLI inside an immutable image breaks reproducibility. |
CLAUDE_CODE_MAX_RETRIES | Bounds retries at 5. Unset, the CLI makes about 10 attempts with doubling backoff, and a hard failure looks like a hang. |
max_budget_usd, effort, max_buffer_size | Optional, applied only when their env vars are set. See Configuration and environment. |
Event translation
For the plain-HTTP surface, run_conversation_stream() yields strings with a small wire format, which __main__.py maps to NDJSON events:
| String | Meaning |
|---|---|
🤖 <first 1000 chars of the message> | First event of every turn |
🛠️ Remote agent executing: <label> | One per distinct tool call, deduplicated by label |
<final text> | The terminal answer: every TextBlock, joined with a blank line |
Error: ... | A terminal error |
AssistantMessage.erroris checked on every assistant message. It carries a category such asauthentication_failed,billing_error,rate_limit,invalid_request,server_error, orunknown. Without this check an auth or billing failure ends the run later with the vaguerproduced no text response.- A run that yields no text ends with
Error: agent completed but produced no text response. - When
ResultMessage.is_erroris set, the core logs itssubtypeandresultso the real reason is visible. - When a run uses 90% or more of
MAX_TURNS, the core logs anapproaching capwarning.
Reading tokens and cost
ResultMessage has no flat input_tokens or output_tokens attributes. Reading them with getattr(msg, "input_tokens", 0) returns 0 on every run, silently. Tokens live in model_usage (per model, camelCase, covers the whole tree including sub-agents), with usage (snake_case, top-level loop only) as the fallback. Input is the sum of uncached, cache-read, and cache-creation tokens: inputTokens alone is only the small uncached remainder.
model_usage = getattr(msg, "model_usage", None)
if isinstance(model_usage, dict) and model_usage:
inp = out = 0
for entry in model_usage.values():
if not isinstance(entry, dict):
continue
inp += (int(entry.get("inputTokens") or 0)
+ int(entry.get("cacheReadInputTokens") or 0)
+ int(entry.get("cacheCreationInputTokens") or 0))
out += int(entry.get("outputTokens") or 0)
if inp or out:
return inp, out
# fallback: msg.usage with input_tokens, cache_read_input_tokens,
# cache_creation_input_tokens, output_tokens (excludes sub-agents)The core stores the result on the instance: last_token_usage (prompt_tokens, completion_tokens, total_tokens), last_cost_usd from total_cost_usd, and last_num_turns.
Progress labels
_describe_tool() turns each tool call into a short label. Replace {{TOOL_SCRIPT_LABELS}} with a map from the skill's script names to labels. Unmapped Bash calls fall back to Bash: <first line of the command>; Skill calls read Loading skill: <name>; file tools read <Tool>: <path or pattern>.
if tool_name == "Bash":
cmd = (tool_input or {}).get("command", "") or ""
for script, label in (
("fetch_sources.py", "Fetching sources"),
("render.py", "Rendering output"),
):
if script in cmd:
return labelPrompt strategies
_build_prompt() decides how every incoming message reaches the SDK. Replace {{PROMPT_STRATEGY_BODY}} with one of two patterns, matching the trigger mode you chose.
Pattern A: single-purpose
Use it when the agent does one thing and every run should run the skill end to end. Even an empty message or a bare go triggers the full pipeline.
def _build_prompt(self, user_message: str) -> str:
directive = (
f"You are a single-purpose agent. Use the **{SKILL_NAME}** skill "
f"to handle the user's request. Read its SKILL.md and run the "
f"full pipeline. Always run the full pipeline; do not ask the "
f"user to clarify unless a required argument is genuinely "
f"ambiguous.\n\nUser request:\n"
)
return directive + (user_message or "").strip()Pattern B: multi-purpose
Use it when the skill has several command surfaces or the request varies from run to run. Claude invokes the skill only when the request matches it, and can decline off-topic requests.
def _build_prompt(self, user_message: str) -> str:
hint = (
f"You have access to the {SKILL_NAME} skill. Use it for "
f"reporting requests. Always pass --format html.\n\n"
)
return hint + (user_message or "").strip()| Question | Single-purpose | Multi-purpose |
|---|---|---|
| Is there exactly one outcome this agent produces? | Yes | |
| Does the user's intent vary turn to turn? | Yes | |
| Does empty or minimal input still have a sensible default action? | Yes | |
| Should the agent refuse off-topic requests? | Yes | |
| Does the skill have many command surfaces (CRUD, reports, queries)? | Yes |
Whichever pattern you pick, confirm that SKILL_NAME matches the skill's frontmatter name: exactly, that allowed_tools includes "Skill", and that setting_sources=["project"] is set.
Related
Last updated 2026-10-21