An extensible AI agent CLI with a plugin system, MCP client, and multi-provider support.
$ ash chat
✦ New session a3f1bc2d (claude-opus-4-7)
plugins: audit_logger
MCP: filesystem (1개 서버)
Type /exit or Ctrl-C to quit.
> list all python files in the current directory
⚙ filesystem__list_directory {"path": "."}
→ [file] cli.py [file] config.py [dir] agent [dir] tools ...
claude-opus-4-7: Here are the Python files in the current directory: cli.py, config.py ...
↑980 ↓124 $0.0180
> /exit
Session cost: $0.0180
Bye.
- Plugin system — drop a
.pyfile into~/.ash-agent/plugins/, get lifecycle hooks and dynamic tool registration with zero configuration - MCP client — connect any stdio or Streamable HTTP MCP server by editing
mcp.json; tools auto-register asserver__tool_name - Session persistence — SQLite or PostgreSQL-backed conversation history; resume any past session with
--session <id> - Full-text search —
ash search <keyword>searches all past conversations via SQLite FTS5 or PostgreSQLtsvector - Cost tracking — real-time token usage and USD cost displayed after every response; session total on exit
- Cron scheduler —
ash schedule add "..." --cron "0 9 * * *"runs prompts on a schedule; results saved to~/.ash-agent/schedule/results/ - Multi-provider — switch between Anthropic (default) and any OpenAI-compatible endpoint (vLLM, etc.) via environment variables
- TUI — optional React/Ink terminal UI (
ash streamordocker compose run tui)
- Dual memory backend — SQLite (zero-config, default) or PostgreSQL (team deployments); switch with
ASH_MEMORY_BACKEND=postgres - Context compression — sliding window compressor keeps the context window under control; configurable via
ASH_CONTEXT_MAX_MESSAGES - Prompt caching — system prompt and tool definitions are marked with
cache_control: ephemeralon every Anthropic request - Error classification + provider fallback — rate limit, server error, and timeout automatically retry against a configured fallback provider; auth and context overflow errors surface immediately
- JSON Lines IPC —
streamcommand emits structured events for programmatic consumers (TUI, CI pipelines)
# Requires Python 3.11+
pip install ash-agent
# Set your API key
export ANTHROPIC_API_KEY=sk-ant-...
# Start chatting
ash chatash chat # Start a new conversation
ash chat --session <id> # Resume a previous session
ash sessions # List saved sessions
ash search "keyword" # Search past conversations
ash schedule add "..." --cron "0 9 * * *" # Schedule a recurring prompt
ash schedule list # View scheduled jobs
ash schedule daemon # Start the scheduler daemonFor shared team deployments with PostgreSQL, see docs/team-deploy.md.
git clone https://github.com/ash-hun/ash-agents.git
cd ash-agents/ash-agent
cp .env.example .env # set ANTHROPIC_API_KEY
docker compose build
docker compose run --rm agentAll configuration is read from environment variables or a .env file.
| Variable | Default | Description |
|---|---|---|
ANTHROPIC_API_KEY |
— | Anthropic API key (required for default provider) |
ASH_DEFAULT_PROVIDER |
anthropic |
Active provider: anthropic or openai_compat |
ASH_DEFAULT_MODEL |
claude-opus-4-7 |
Model name passed to the Anthropic provider |
VLLM_BASE_URL |
http://localhost:8000/v1 |
Base URL for the OpenAI-compatible endpoint |
VLLM_API_KEY |
token-abc123 |
API key for the OpenAI-compatible endpoint |
VLLM_MODEL |
mistral-7b-instruct |
Model name for the OpenAI-compatible endpoint |
ASH_MAX_ITERATIONS |
20 |
Maximum tool-use iterations per turn |
ASH_DB_PATH |
~/.ash-agent/sessions.db |
SQLite database file path |
ASH_MEMORY_BACKEND |
sqlite |
Memory backend: sqlite or postgres |
DATABASE_URL |
(disabled) | PostgreSQL URL — required when ASH_MEMORY_BACKEND=postgres |
ASH_CONTEXT_STRATEGY |
sliding_window |
Context compression strategy: sliding_window or noop |
ASH_CONTEXT_MAX_MESSAGES |
40 |
Maximum messages kept by the sliding window compressor |
ASH_PLUGIN_DIR |
~/.ash-agent/plugins |
User-global plugin directory |
ASH_FALLBACK_PROVIDER |
(disabled) | Provider to use on rate limit / server error / timeout |
ASH_MCP_CONFIG |
(auto-discover) | Explicit path to mcp.json; auto-discovers ./mcp.json then ~/.ash-agent/mcp.json |
TAVILY_API_KEY |
(disabled) | Tavily API key — required to enable web_search |
Plugins are plain Python files discovered at startup from two locations:
~/.ash-agent/plugins/— user-global, applies to every project.ash-agent/plugins/— project-local, relative to the working directory
Files prefixed with _ are ignored. Each file must expose a register(ctx) function.
Step 1 — create the plugin file:
mkdir -p ~/.ash-agent/plugins
touch ~/.ash-agent/plugins/audit_logger.pyStep 2 — implement register(ctx: PluginContext):
# ~/.ash-agent/plugins/audit_logger.py
import logging
log = logging.getLogger("audit")
def register(ctx):
# Hook: called before every tool execution
def pre_tool(name: str, input: dict):
log.info("TOOL %s %s", name, input)
# Hook: called after every tool execution
def post_tool(name: str, input: dict, result):
log.info("RESULT %s -> %s", name, str(result)[:200])
ctx.on_pre_tool_call(pre_tool)
ctx.on_post_tool_call(post_tool)Available hooks:
| Hook | Signature | Fires |
|---|---|---|
on_pre_tool_call |
(name: str, input: dict) |
Before each tool execution |
on_post_tool_call |
(name: str, input: dict, result: Any) |
After each tool execution |
on_pre_llm_call |
(messages: list, tools: list, system: str) |
Before each LLM call |
on_post_llm_call |
(response: ProviderResponse) |
After each LLM response |
All hooks support both sync and async functions. A hook that raises an exception does not interrupt the agent loop.
Registering a custom tool from a plugin:
def register(ctx):
async def get_weather(city: str) -> str:
# ... call your weather API
return f"Sunny in {city}"
ctx.register_tool(
name="get_weather",
description="Get current weather for a city.",
schema={
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
fn=get_weather,
)The tool is available to the agent immediately — no restart needed beyond the current session startup.
Create a mcp.json file in your working directory (or at ~/.ash-agent/mcp.json for user-global config):
{
"servers": {
"filesystem": {
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {}
},
"git": {
"transport": "stdio",
"command": "uvx",
"args": ["mcp-server-git", "--repository", "."],
"env": {}
},
"my-remote-server": {
"transport": "sse",
"url": "http://localhost:3001",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
}
}
}
}Transport types:
| Transport | Field | Description |
|---|---|---|
stdio |
command, args, env |
Spawns a local subprocess; communicates over stdin/stdout JSON-RPC |
sse |
url, headers |
Connects to a Streamable HTTP MCP server (MCP 2025 spec); POSTs to {url}/mcp |
Tool naming convention:
Tools from MCP servers are registered as {server_name}__{tool_name}. For example, a tool named list_directory from a server named filesystem is available to the agent as filesystem__list_directory.
Discovery is automatic — the agent calls tools/list at startup and registers every tool it finds.
| Tool | Description |
|---|---|
read_file |
Read text file contents from a path |
write_file |
Write or overwrite a text file; creates parent directories automatically |
list_dir |
List files and subdirectories at a given path |
run_command |
Execute a shell command and return stdout/stderr; hard timeout of 60 seconds |
web_fetch |
Fetch a URL and return stripped plain text (HTML tags removed, 8000-char cap) |
web_search |
Search the web via Tavily API and return ranked results; requires TAVILY_API_KEY |
┌─────────────────────────────────────────────────────────────┐
│ CLI (cli.py) │
│ chat | sessions | stream │
└──────────────────────────┬──────────────────────────────────┘
│
┌───────────────▼───────────────┐
│ Agent Loop (agent/loop.py) │
│ while tool_use: │
│ pre_llm_hook → LLM call │
│ → post_llm_hook │
│ pre_tool_hook → dispatch │
│ → post_tool_hook │
└──┬──────────┬────────┬────────┘
│ │ │
┌──────────▼──┐ ┌────▼────┐ ┌▼──────────────┐
│ Providers │ │ Memory │ │ Tool Registry │
│ Anthropic │ │ SQLite │ │ built-in + │
│ OpenAI │ │ PostgreSQL│ │ MCP tools + │
│ Fallback │ └────────────┘ │ plugin tools │
└─────────────┘ └────────────────┘
│
┌──────────▼──────────┐
│ Context Compressor │
│ sliding_window │
│ noop │
└─────────────────────┘
Plugin system: pre/post hooks fire at each ▲ boundary above
MCP client: stdio subprocess or Streamable HTTP → tool auto-registration
Key layers:
cli.py— entry point; wires up all components and owns the REPL / JSON Lines streamagent/loop.py— stateless agentic loop; accepts anyBaseProvider,BaseMemory, andToolRegistryagent/providers/—BaseProviderabstraction;AnthropicProvider,OpenAICompatProvider,FallbackProvideragent/context/—ContextCompressorabstraction;SlidingWindowCompressor,NoopCompressormemory/sqlite.py—BaseMemorySQLite implementation; FTS5 full-text search, WAL modememory/postgres.py—BaseMemoryPostgreSQL implementation;tsvectorFTS, GIN indextools/registry.py—@registry.tool()decorator; sync and async dispatchplugins/__init__.py—PluginManager; file-based discovery, hook emissionagent/mcp/—MCPManager; transport abstraction (StdioTransport,StreamableHTTPTransport)
ash-agent/
├── cli.py # CLI entry point (chat / sessions / stream / search / schedule)
├── config.py # Pydantic Settings — all env variables
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── mcp.json.example
│
├── agent/
│ ├── loop.py # Main agent loop
│ ├── prompt.py # System prompt builder
│ ├── providers/
│ │ ├── base.py # BaseProvider, ProviderResponse, StopReason
│ │ ├── anthropic_provider.py
│ │ ├── openai_compat.py
│ │ ├── fallback.py # FallbackProvider wrapper
│ │ └── error.py # Error classification
│ ├── context/
│ │ ├── base.py # ContextCompressor interface
│ │ ├── sliding_window.py
│ │ └── noop.py
│ └── mcp/
│ ├── __init__.py # MCPManager
│ ├── config.py # MCPConfig, StdioServerConfig, SSEServerConfig
│ └── transport.py # StdioTransport, StreamableHTTPTransport
│
├── memory/
│ ├── base.py # BaseMemory, SessionMeta, MessageRow
│ ├── sqlite.py # SQLiteMemory (FTS5)
│ └── postgres.py # PostgresMemory (tsvector + GIN)
│
├── tools/
│ ├── registry.py # ToolRegistry, @registry.tool()
│ ├── file_tools.py # read_file, write_file, list_dir
│ ├── shell_tools.py # run_command
│ ├── web_tools.py # web_fetch
│ └── search_tools.py # web_search (Tavily)
│
├── cron/
│ ├── models.py # ScheduledJob dataclass
│ ├── store.py # JobStore — SQLite CRUD
│ ├── runner.py # run_job() — executes a job, saves result file
│ └── daemon.py # start() — scheduler loop (croniter)
│
├── plugins/ # (empty — user plugins go in ~/.ash-agent/plugins/)
│
└── ui-tui/ # Optional React/Ink TUI
└── src/
├── index.tsx
├── app.tsx
├── agentProcess.ts # subprocess spawn + JSON Lines IPC
└── hooks/useAgent.ts
- Fork the repository and create a feature branch
- Install dev dependencies:
pip install -e ".[dev]" - For PostgreSQL backend:
pip install -e ".[dev,postgres]" - Run the test suite:
pytest - Keep commits focused — one concern per commit
- Open a pull request with a clear description of the change
New providers, memory backends, and context compression strategies can be added by implementing the corresponding abstract base class (BaseProvider, BaseMemory, ContextCompressor) and wiring it into cli.py.
MIT