AgentSkillsCN

create-claude-agent

使用 Claude Agent SDK 创建生产就绪的 Claude Agent 项目。

SKILL.md
--- frontmatter
name: create-claude-agent
description: Create production-ready Claude agent projects using the Claude Agent SDK.

Create Claude Agent

Instructions

  1. Ask user about agent's purpose and required tools (Bash, Read, Write, Edit, Grep, Glob, WebFetch, Skill)
  2. Detect working folder (see below)
  3. Check for naming conflicts (existing agent with same name)
  4. Create project structure using templates
  5. Customize task.md for agent's task
  6. Customize .claude/settings.local.json permissions based on required tools
  7. Initialize git repository if not exists
  8. Create virtual environment and install dependencies:
    • Run python3 -m venv .venv
    • Activate venv and run pip install -r requirements.txt
  9. Output concise confirmation with next steps (no .md explanation files)

Prerequisites

Before creating an agent, verify:

  • Python 3.10+ is installed
  • pip is available
  • Claude CLI is installed (npm install -g @anthropic-ai/claude-code or brew install claude)
  • ANTHROPIC_API_KEY is set in .env file

Working Folder Detection

Before creating files, detect the project structure:

  1. Check if folder is empty or has existing structure (look for src/, agents/, scripts/, etc.)
  2. Determine agent location based on structure:
ConditionAgent LocationProject Root
Empty folder./src/, ./logs/, ./claude_agent.pyCurrent dir
Has src/src/agents/{agent-name}/Current dir
Has agents/agents/{agent-name}/Current dir
Has scripts/scripts/{agent-name}/Current dir
Other./{agent-name}/Current dir
  1. Check for naming conflicts — if agent location exists, prompt user to overwrite, rename, or abort
  2. Place project-level files at root:
    • .vscode/launch.json → project root (merge if exists)
    • requirements.txt → project root (merge if exists)
    • .gitignore → project root (merge if exists)
    • .env.example → project root (create if not exists)

Merging Existing Files

When project-level files already exist, merge rather than overwrite:

  • .vscode/launch.json: Append new configuration to configurations array (avoid duplicates by name)
  • requirements.txt: Append only missing packages
  • .gitignore: Append only missing patterns

Post-Creation Steps

After creating files:

  1. Initialize git (if not already a repo)
  2. Create virtual environment:
    bash
    python3 -m venv .venv
    
  3. Activate and install dependencies:
    bash
    source .venv/bin/activate  # Unix/macOS
    pip install -r requirements.txt
    

Project Structure

code
{project-root}/
├── .vscode/
│   └── launch.json                 # Debug config
├── .gitignore
├── requirements.txt
├── {agent-location}/               # Adaptive (see above)
│   ├── .claude/
│   │   └── settings.local.json
│   ├── src/
│   │   ├── __init__.py
│   │   ├── agent.py
│   │   ├── schemas.py
│   │   └── exceptions.py
│   ├── logs/                       # Execution logs
│   ├── output/                     # Message history (JSONL)
│   ├── task.md
│   └── claude_agent.py

File Templates

requirements.txt

txt
claude-agent-sdk
pydantic
python-dotenv
colorlog

Note: Use claude-agent-sdk. The SDK wraps the Claude CLI.

.env.example

env
# Required: Your Anthropic API key
ANTHROPIC_API_KEY=your-api-key-here

# Optional: Agent configuration
# AGENT_MODEL=claude-sonnet-4-5
# AGENT_MAX_TURNS=50
# AGENT_TIMEOUT_SECONDS=1800
# AGENT_MAX_BUDGET_USD=10.0
# AGENT_PERMISSION_MODE=bypassPermissions  # default: bypassPermissions (allows all tools)

.gitignore

gitignore
# Agent logs and output
logs/
output/
*.jsonl

# Environment and secrets
.env
.env.local

# Python
__pycache__/
*.pyc
*.pyo
.venv/
venv/
*.egg-info/

# IDE
.idea/
*.swp
*.swo

.claude/settings.local.json

Customize permissions based on agent's required capabilities:

json
{
  "permissions": {
    "allow": [
      "Bash(curl:*)",
      "Bash(wget:*)",
      "Bash(git:*)",
      "Bash(find:*)",
      "Bash(grep:*)",
      "Bash(ls:*)",
      "Bash(cat:*)",
      "Bash(python:*)",
      "Bash(jq:*)",
      "Bash(date:*)"
    ],
    "deny": [
      "Bash(rm -rf:*)",
      "Bash(sudo:*)",
      "Bash(chmod 777:*)",
      "Bash(> /dev:*)"
    ]
  }
}

Permission Customization Guide:

Agent TypeAdditional AllowAdditional Deny
Web scraperBash(node:*)Bash(ssh:*)
Data processorBash(jq:*), Bash(awk:*)Bash(curl:*)
DevOpsBash(docker:*), Bash(kubectl:*)
File managerBash(mv:*), Bash(cp:*)Bash(curl:*)

Security: Start with minimal permissions, add as needed. Never allow sudo or destructive operations without explicit user confirmation.

.vscode/launch.json

Use {agent-path} relative to project root. Uses cross-platform Python path.

Critical: Do NOT use envFile - the SDK uses Claude CLI authentication, not environment variables.

json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run {agent-name}",
      "type": "debugpy",
      "request": "launch",
      "python": "${command:python.interpreterPath}",
      "program": "${workspaceFolder}/{agent-path}/claude_agent.py",
      "args": [],
      "console": "integratedTerminal",
      "justMyCode": true,
      "env": {
        "PYTHONPATH": "${workspaceFolder}/{agent-path}",
        "PYTHONUNBUFFERED": "1",
      },
      "cwd": "${workspaceFolder}/{agent-path}",
      "envFile": "${workspaceFolder}/.env"
    }
  ]
}

Cross-platform note: Using ${command:python.interpreterPath} ensures compatibility with Windows and Unix systems. Requires Python extension in VS Code/Cursor.

task.md

markdown
# Agent Task

## Objective
Describe what the agent should accomplish.

## Instructions
1. Step one
2. Step two
3. Step three

## Expected Output
Describe what output.json should contain.

src/init.py

python
"""Agent source package."""

src/exceptions.py

python
"""Agent exceptions."""


class AgentError(Exception):
    """Base exception."""
    pass


class ConfigurationError(AgentError):
    """Configuration error."""
    pass


class SessionIncompleteError(AgentError):
    """Session did not complete."""
    pass


class MaxTurnsExceededError(AgentError):
    """Exceeded turn limit."""
    pass


class ServerError(AgentError):
    """API error."""
    pass

src/schemas.py

python
"""Data models."""
from enum import StrEnum
from typing import Any, Optional

from pydantic import BaseModel, Field


class TaskStatus(StrEnum):
    SUCCESS = "SUCCESS"
    PARTIAL = "PARTIAL"
    FAILED = "FAILED"
    ERROR = "ERROR"


class LLMMetrics(BaseModel):
    """Session metrics."""
    model: str
    duration_ms: int
    num_turns: int
    session_id: str
    total_cost_usd: Optional[float] = None


class AgentResult(BaseModel):
    """Agent execution result."""
    status: TaskStatus
    summary: str
    details: Optional[str] = None
    data: dict[str, Any] = Field(default_factory=dict)
    metrics: Optional[LLMMetrics] = None
    error: Optional[str] = None
    message_history: list[dict[str, Any]] = Field(default_factory=list)

src/agent.py

python
"""Agent execution logic."""
import asyncio
import logging
import os
import time
from typing import Any, Optional

from claude_agent_sdk import query
from claude_agent_sdk.types import (
    AssistantMessage,
    ClaudeAgentOptions,
    ResultMessage,
    SystemMessage,
    UserMessage,
)

from .exceptions import (
    AgentError,
    MaxTurnsExceededError,
    ServerError,
    SessionIncompleteError,
)
from .schemas import AgentResult, LLMMetrics, TaskStatus

logger = logging.getLogger(__name__)

# ANSI color codes for verbose output
BLUE = "\033[94m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
RESET = "\033[0m"
BOLD = "\033[1m"


def serialize_block(block: Any) -> dict[str, Any]:
    """Convert SDK message block to serializable dict."""
    block_type = getattr(block, 'type', None)
    if block_type == 'tool_use':
        return {
            "type": "tool_use",
            "name": getattr(block, 'name', ''),
            "input": getattr(block, 'input', {}),
        }
    elif block_type == 'tool_result':
        content = getattr(block, 'content', '')
        return {
            "type": "tool_result",
            "is_error": getattr(block, 'is_error', False),
            "content": str(content)[:500] if content else "",
        }
    elif block_type == 'text':
        return {"type": "text", "text": getattr(block, 'text', '')}
    return {"type": str(block_type), "raw": str(block)[:200]}


def print_verbose(prefix: str, content: str, color: str = RESET) -> None:
    """Print verbose output with colored prefix."""
    print(f"{color}{BOLD}[{prefix}]{RESET} {content}", flush=True)


async def run_agent_with_timeout(
    task: str,
    working_dir: str = ".",
    additional_dirs: Optional[list[str]] = None,
    verbose: bool = True,
) -> AgentResult:
    """Run the agent with configuration from environment."""
    max_turns = int(os.getenv("AGENT_MAX_TURNS", "50"))
    timeout_seconds = int(os.getenv("AGENT_TIMEOUT_SECONDS", "1800"))
    max_budget = float(os.getenv("AGENT_MAX_BUDGET_USD", "10.0"))
    model = os.getenv("AGENT_MODEL", "claude-sonnet-4-5")
    permission_mode = os.getenv("AGENT_PERMISSION_MODE", "bypassPermissions")

    logger.info(f"Starting agent with model={model}, max_turns={max_turns}")

    # add_dirs grants the agent read/write access to directories outside cwd.
    # Use this when the agent needs to access external resources, configs, or data.
    options = ClaudeAgentOptions(
        model=model,
        cwd=working_dir,
        permission_mode=permission_mode,
        max_turns=max_turns,
        max_budget_usd=max_budget,
        add_dirs=additional_dirs or [],  # Additional allowed directories
    )

    try:
        result_text = ""
        session_id = ""
        num_turns = 0
        total_cost = None
        message_history: list[dict[str, Any]] = []

        async def run_query():
            nonlocal result_text, session_id, num_turns, total_cost
            turn_count = 0

            async for message in query(prompt=task, options=options):
                if isinstance(message, AssistantMessage):
                    turn_count += 1
                    content = getattr(message, 'message', None)
                    serialized_blocks: list[dict[str, Any]] = []
                    if content:
                        msg_content = getattr(content, 'content', None)
                        if msg_content:
                            for block in msg_content:
                                serialized_blocks.append(serialize_block(block))
                                if verbose:
                                    block_type = getattr(block, 'type', None)
                                    if block_type == 'text':
                                        text = getattr(block, 'text', '')
                                        if text:
                                            preview = text[:500] + ("..." if len(text) > 500 else "")
                                            print_verbose("ASSISTANT", preview, GREEN)
                                    elif block_type == 'tool_use':
                                        tool_name = getattr(block, 'name', 'unknown')
                                        tool_input = getattr(block, 'input', {})
                                        print_verbose("TOOL", f"{tool_name}", YELLOW)
                                        if tool_name == 'Bash' and 'command' in tool_input:
                                            print_verbose("CMD", tool_input['command'][:200], MAGENTA)
                    message_history.append({
                        "turn": turn_count,
                        "role": "assistant",
                        "timestamp": time.time(),
                        "content": serialized_blocks,
                    })
                    if verbose:
                        print_verbose(f"TURN {turn_count}", "", CYAN)

                elif isinstance(message, UserMessage):
                    content = getattr(message, 'message', None)
                    if content:
                        msg_content = getattr(content, 'content', None)
                        if msg_content and verbose:
                            for block in msg_content:
                                block_type = getattr(block, 'type', None)
                                if block_type == 'tool_result':
                                    result_content = getattr(block, 'content', '')
                                    if isinstance(result_content, str) and result_content:
                                        preview = result_content[:300] + ("..." if len(result_content) > 300 else "")
                                        print_verbose("RESULT", preview, BLUE)

                elif isinstance(message, SystemMessage):
                    if verbose:
                        print_verbose("SYSTEM", str(message)[:200], CYAN)

                if isinstance(message, ResultMessage):
                    result_text = getattr(message, 'result', str(message))
                    session_id = getattr(message, 'session_id', '')
                    num_turns = getattr(message, 'num_turns', turn_count)
                    total_cost = getattr(message, 'total_cost_usd', None)
                    if verbose:
                        cost_str = f"${total_cost:.4f}" if total_cost else "N/A"
                        print_verbose("COMPLETE", f"Turns: {num_turns}, Cost: {cost_str}", GREEN)

        await asyncio.wait_for(run_query(), timeout=timeout_seconds)

        metrics = LLMMetrics(
            model=model,
            duration_ms=0,
            num_turns=num_turns,
            session_id=session_id,
            total_cost_usd=total_cost,
        )

        return AgentResult(
            status=TaskStatus.SUCCESS,
            summary="Task completed successfully",
            details=result_text,
            metrics=metrics,
            message_history=message_history,
        )

    except asyncio.TimeoutError:
        raise AgentError(f"Agent timed out after {timeout_seconds} seconds")
    except Exception as e:
        error_msg = str(e)
        if "max_turns" in error_msg.lower():
            raise MaxTurnsExceededError(f"Exceeded {max_turns} turns")
        if "server" in error_msg.lower() or "api" in error_msg.lower():
            raise ServerError(f"API error: {error_msg}")
        if "incomplete" in error_msg.lower():
            raise SessionIncompleteError(f"Session incomplete: {error_msg}")
        raise AgentError(f"Agent execution failed: {error_msg}")

claude_agent.py

python
#!/usr/bin/env python3
"""
Claude Agent entry point.

Usage:
    python claude_agent.py                    # Run with default task.md
    python claude_agent.py --task-file x.md   # Custom task file
    python claude_agent.py --output out.json  # Save output to file
    python claude_agent.py -v                 # Verbose output

Prerequisites:
    pip install -r requirements.txt
    Set ANTHROPIC_API_KEY in .env file
"""
import argparse
import asyncio
import json
import logging
import sys
from datetime import datetime
from pathlib import Path

import colorlog
from dotenv import load_dotenv

from src.agent import run_agent_with_timeout
from src.schemas import AgentResult, TaskStatus

# Load environment variables from .env file
load_dotenv()


def setup_logging(logs_dir: Path) -> logging.Logger:
    """Configure logging with console and file handlers."""
    logs_dir.mkdir(parents=True, exist_ok=True)

    # Console handler with colors
    console_handler = colorlog.StreamHandler()
    console_handler.setFormatter(colorlog.ColoredFormatter(
        "%(log_color)s%(asctime)s [%(levelname)s]%(reset)s %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
        log_colors={
            "DEBUG": "cyan",
            "INFO": "green",
            "WARNING": "yellow",
            "ERROR": "red",
            "CRITICAL": "red,bg_white",
        },
    ))

    # File handler for persistent logs
    log_file = logs_dir / f"agent_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
    file_handler = logging.FileHandler(log_file, encoding="utf-8")
    file_handler.setFormatter(logging.Formatter(
        "%(asctime)s [%(levelname)s] %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    ))

    logger = colorlog.getLogger(__name__)
    logger.addHandler(console_handler)
    logger.addHandler(file_handler)
    logger.setLevel(logging.INFO)
    return logger


def load_task_file(task_file: Path) -> str:
    if not task_file.exists():
        raise FileNotFoundError(f"Task file not found: {task_file}")
    return task_file.read_text(encoding="utf-8")


def save_message_history(
    result: AgentResult,
    output_dir: Path,
    session_id: str,
    logger: logging.Logger,
) -> None:
    """Save message history to JSONL file."""
    if not result.message_history:
        return
    output_dir.mkdir(parents=True, exist_ok=True)
    output_file = output_dir / f"session_{session_id}.jsonl"
    with output_file.open("w", encoding="utf-8") as f:
        for msg in result.message_history:
            f.write(json.dumps(msg) + "\n")
    logger.info(f"Message history saved to: {output_file}")


def main() -> int:
    base_dir = Path(__file__).parent
    logs_dir = base_dir / "logs"
    output_dir = base_dir / "output"
    logger = setup_logging(logs_dir)

    parser = argparse.ArgumentParser(description="Execute Claude agent task")
    parser.add_argument(
        "--dir",
        default=".",
        help="Working directory (default: current)",
    )
    parser.add_argument(
        "--task-file",
        type=Path,
        default=Path("task.md"),
        help="Task file (default: task.md)",
    )
    parser.add_argument(
        "--output",
        help="Output file path (default: stdout)",
    )
    parser.add_argument(
        "--add-dir",
        action="append",
        default=[],
        help="Additional directories",
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Enable verbose output",
    )
    args = parser.parse_args()

    if args.verbose:
        logger.setLevel(logging.DEBUG)

    try:
        task: str = load_task_file(args.task_file)
    except FileNotFoundError as e:
        logger.error(str(e))
        return 1

    logger.info(f"Task file: {args.task_file}")
    logger.info(f"Working directory: {args.dir}")

    try:
        result = asyncio.run(
            run_agent_with_timeout(
                task=task,
                working_dir=args.dir,
                additional_dirs=args.add_dir,
                verbose=args.verbose,
            )
        )

        # Save message history to JSONL
        session_id = (
            result.metrics.session_id
            if result.metrics
            else datetime.now().strftime("%Y%m%d_%H%M%S")
        )
        save_message_history(result, output_dir, session_id, logger)

        # Output result JSON (excluding message_history to avoid duplication)
        output_json: str = result.model_dump_json(
            indent=2, exclude={"message_history"}
        )

        if args.output:
            Path(args.output).write_text(output_json, encoding="utf-8")
            logger.info(f"Result saved to: {args.output}")
        else:
            print(output_json)

        if result.metrics:
            logger.info(
                f"Turns: {result.metrics.num_turns}, "
                f"Cost: ${result.metrics.total_cost_usd:.4f}"
                if result.metrics.total_cost_usd
                else f"Turns: {result.metrics.num_turns}"
            )

        return 0 if result.status == TaskStatus.SUCCESS else 1

    except Exception as e:
        logger.error(f"Agent failed: {e}")
        return 1


if __name__ == "__main__":
    sys.exit(main())

Auxiliary script

You may want to create additional python script as part of the agentic tools so it could use them instead of direct access to API or database. If so, add corresponding instructions to the agent promt (task.md file).

Completion Output

After creating the agent, output only:

code
✓ Agent "{agent-name}" created at {agent-path}/
✓ Virtual environment created and dependencies installed

To run:
  # Copy .env.example to .env and set your API key
  cp .env.example .env
  # Edit .env and add your ANTHROPIC_API_KEY
  
  # Activate virtual environment
  source .venv/bin/activate      # Unix/macOS
  # .\.venv\Scripts\Activate.ps1  # Windows PowerShell
  
  # Run the agent
  python {agent-path}/claude_agent.py

Or press F5 in VS Code/Cursor (select .venv interpreter first).

Available Tools

ToolCapability
BashExecute shell commands
ReadRead file contents
WriteCreate/overwrite files
EditModify existing files
GrepSearch patterns in files
GlobFind files by pattern
WebFetchFetch content from URLs
SkillExecute custom skills

Configuration Options

Environment variables for customization:

VariableDefaultDescription
AGENT_MODELclaude-sonnet-4-5Model alias
AGENT_MAX_TURNS50Maximum conversation turns
AGENT_TIMEOUT_SECONDS1800Execution timeout
AGENT_MAX_BUDGET_USD10.0Maximum API cost
AGENT_PERMISSION_MODEbypassPermissionsPermission mode

Valid permission modes:

  • default - CLI prompts for dangerous tools
  • acceptEdits - Auto-accept file edits
  • plan - Planning mode, no execution
  • bypassPermissions - Allow all tools (use with caution)

Valid model aliases:

  • claude-sonnet-4-5 - Fast, capable (recommended)
  • claude-opus-4 - Most capable

SDK Options (ClaudeAgentOptions):

ParameterTypeDescription
cwdstrWorking directory for the agent
add_dirslist[str]Additional directories the agent can access (read/write). Use when agent needs files outside cwd — e.g., shared configs, data directories, or external resources
modelstrModel alias
permission_modestrPermission mode (see above)
max_turnsintMaximum conversation turns
max_budget_usdfloatMaximum API cost

Troubleshooting

Common Issues

ProblemCauseSolution
ModuleNotFoundError: claude_agent_sdkPackage not installedRun pip install claude-agent-sdk
Invalid API keyAPI key missing or invalidCheck .env file has valid ANTHROPIC_API_KEY
Command failed with exit code 1Various causesCheck logs, verify API key, check Claude CLI installation
python3: command not foundPython not in PATHInstall Python 3.10+ or fix PATH
venv creation failedMissing python3-venvsudo apt install python3-venv (Linux)
Agent timeoutTask too complexIncrease AGENT_TIMEOUT_SECONDS or simplify task
Logs directory growingNo cleanupManually clean logs/ directory
Permission denied errorsRestrictive settings.local.jsonAdd required commands to allow list

Authentication

Set your API key in the .env file:

bash
# Copy example and edit
cp .env.example .env

# Add your key to .env
ANTHROPIC_API_KEY=sk-ant-api03-xxxxx...

Verify the key works (from within existing venv):

bash
curl -s https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-4-5-20250929","max_tokens":10,"messages":[{"role":"user","content":"Hi"}]}'

Debugging Tips

  1. Check logs: Review logs/agent_{timestamp}.log for detailed execution trace
  2. Verify API key: Run echo $ANTHROPIC_API_KEY | head -c 15 (should show sk-ant-api...)
  3. Test SDK: Run python -c "from claude_agent_sdk import query; print('OK')"
  4. Check permissions: Verify .claude/settings.local.json allows required operations
  5. Inspect message history: Check output/session_{session_id}.jsonl for full conversation trace
  6. Test CLI: Run claude -p "hello" to verify Claude CLI works

Platform-Specific Notes

macOS/Linux:

  • Use source .venv/bin/activate
  • Set env vars with export VAR=value
  • Load .env automatically with python-dotenv

Windows:

  • Use .\.venv\Scripts\Activate.ps1 (PowerShell) or .venv\Scripts\activate.bat (CMD)
  • Set env vars with $env:VAR="value" (PowerShell) or set VAR=value (CMD)
  • Path separators: use \ in paths or raw strings

Docker:

  • Mount .env file: -v $(pwd)/.env:/app/.env
  • Or set env directly: -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY