# The agent core and prompt strategies

Agent Mission Control developer docs > Claude skill, self-hosted

> How `claude_skills_agent.py` configures `claude-agent-sdk`, steers each message at the skill, and reads tokens and cost.

**Before you start:** A generated agent directory. See [Project layout and placeholders](/developers/self-hosted-project-layout/).

`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

`claude_skills_agent.py (inside run_conversation_stream)`
```python
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](https://www.agentmissioncontrol.ai/developers/self-hosted-configuration/). |

## 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.error` is checked on every assistant message. It carries a category such as `authentication_failed`, `billing_error`, `rate_limit`, `invalid_request`, `server_error`, or `unknown`. Without this check an auth or billing failure ends the run later with the vaguer `produced no text response`.
- A run that yields no text ends with `Error: agent completed but produced no text response`.
- When `ResultMessage.is_error` is set, the core logs its `subtype` and `result` so the real reason is visible.
- When a run uses 90% or more of `MAX_TURNS`, the core logs an `approaching cap` warning.

## 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.

`claude_skills_agent.py (_read_usage)`
```python
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>`.

`claude_skills_agent.py (_describe_tool)`
```python
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 label
```

## Prompt 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.

`Pattern A`
```python
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.

`Pattern B`
```python
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 |

> **Note:** When in doubt, choose multi-purpose. It does not run when nobody asked, and it degrades to doing nothing instead of running the wrong thing.

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

- [The plain-HTTP surface and executor](https://www.agentmissioncontrol.ai/developers/self-hosted-http-surface/)
- [Serve the agent to AMC with amc_sdk](https://www.agentmissioncontrol.ai/developers/self-hosted-amc-sdk/)
- [Configuration and environment](https://www.agentmissioncontrol.ai/developers/self-hosted-configuration/)

---
Source: https://www.agentmissioncontrol.ai/developers/self-hosted-agent-core/ · Last updated 2026-10-21 · Full index: https://www.agentmissioncontrol.ai/llms.txt
