Custom Agents
An open-source, terminal-native AI coding assistant. Like Claude Code — but you own it. Runs any OpenAI-compatible model. 5 specialized agents, 35+ tools, parallel teams, persistent kanban, and a plugin system built for extensibility.
What is Custom Agents?
Custom Agents is a fully terminal-native AI coding assistant. You interact with it through your terminal — there is no browser, no IDE plugin, and no cloud dashboard. The application renders its interactive UI using React + Ink, which renders React components directly inside the terminal instead of a browser DOM.
At its heart, Custom Agents is a tool-calling AI loop. You send a natural language instruction; the AI thinks, selects tools, executes them (reading files, running shell commands, searching the web, editing code), reads the results, and continues until it has a complete answer or solution. This loop is orchestrated through the Query Loop — the core engine of the entire system.
What distinguishes Custom Agents from simpler AI assistants is its multi-agent architecture. Instead of a single monolithic AI, it ships five specialized agents (Explorer, Coder, Reviewer, Documenter, Architect), each with scoped tool access and tuned system prompts. These agents can also be spawned in parallel into agent teams that communicate through an in-memory mailbox and coordinate work through a shared task graph with dependency tracking.
Key mental model: Think of Custom Agents as a local engineering team in a box. You are the product manager — you describe what you need. The lead agent routes your request to the right specialist(s), they use real tools on your real codebase, report back, and you see the work happen in real time in your terminal.
Problems It Solves
| Without Custom Agents | With Custom Agents |
|---|---|
| Copy-paste code into ChatGPT — lose all file context | Agent reads your actual files and understands your full project structure |
| Manually apply AI-suggested edits to your files | Agent writes and edits files directly, showing inline diffs |
| Switch constantly between browser and terminal | Everything happens in your terminal — no context switching |
| One generic AI for everything | Specialized agents tuned for exploration, coding, review, docs, architecture |
| Run one task at a time | Spawn parallel agent teams that coordinate and share a task board |
| Vendor lock-in to a single provider | Use any OpenAI-compatible API — OpenRouter, Ollama, LM Studio, OpenAI, etc. |
System Architecture
The system is organized into twelve clearly bounded modules. Each module owns a distinct responsibility, and they interact through well-defined interfaces — TypeScript types, async functions, and event hooks — rather than direct coupling. Below is the high-level architecture map.
Module Responsibilities
| Module | Path | Responsibility |
|---|---|---|
| Query Loop | src/query/ | The core AI loop — streams tokens from the LLM, executes tool calls, manages turns, triggers compaction |
| Tool Registry | src/tools/ | Registers all 35+ tools, converts them to OpenAI function-call schema, dispatches execution |
| Agent Router | src/agents/ | Resolves agent names to definitions, manages built-in and custom agent store |
| Teams | src/teams/ | Parallel multi-agent execution, teammate state tracking, in-memory mailbox |
| Task Manager | src/tasks/ | In-memory task graph with status transitions, dependency tracking, atomic claiming |
| Kanban Board | src/kanban/ | File-backed persistent Kanban board; cards, sub-tasks, columns, priority, labels |
| Skills | src/skills/ | Slash command system — built-in and user-created custom slash commands |
| Memory | src/memory/ | File-backed key-value memory across three scopes: project, user, session |
| Persistence | src/persistence/ | Saves and loads session transcripts (conversation history) to/from disk |
| Models | src/models/ | Per-agent model profile store — lets different agents use different LLMs |
| Hooks | src/hooks/ | Lifecycle event system — emit and subscribe to events like query:before, team:start |
| Plugins | src/plugins/ | Extensibility layer for loading third-party tools, hooks, and skills |
| Terminal UI | src/components/ · src/screens/ | All terminal rendering using React + Ink — REPL screen, streaming output, team status panel |
End-to-End Data Flow
Every interaction follows a consistent path from keyboard input to final terminal output. Understanding this path is key to understanding how all modules connect in practice.
The Query Loop — Core Engine
The query loop (src/query/query.ts) is the central engine of the entire system. Every agent, every team member, and every slash command ultimately runs through runQueryLoop(). It implements the agentic AI loop: stream tokens from an LLM, detect tool calls, execute them, feed results back, and repeat until the model produces a final answer or the turn limit is reached.
Key Implementation Details
The loop receives an abortSignal so any turn can be cancelled — pressing Ctrl+C sends the abort, and the loop exits cleanly at the next check point. The setAppState function wires streaming tokens directly to React state, so every token appears in the terminal the moment it arrives from the API.
Tool calls are executed through executeToolCalls() in src/tools/orchestration.ts. Multiple tool calls in a single assistant message are dispatched in a loop, with their results all appended before the next LLM turn begins. This means the model sees all tool results at once on the next turn, which is the most common multi-tool pattern.
Max turns guard: The loop has a hard ceiling defined by config.maxTurns (default: 20). Individual agents override this — Explorer caps at 8 turns, Coder at 15. This prevents runaway loops burning tokens on unresolvable tasks.
Streaming & Context Compaction
Streaming
The streamChatCompletion() function in src/query/streamOpenAI.ts connects to any OpenAI-compatible endpoint using Server-Sent Events (SSE). Each chunk that arrives is passed to an onToken callback, which updates React state, causing Ink to re-render the terminal output in real time. The full assistant message is accumulated from all chunks and returned when the stream ends.
Context Compaction
LLMs have finite context windows. Custom Agents tracks a configurable context budget (default: 120,000 tokens). Before each LLM turn, it estimates the total tokens in the message array using a conservative approximation (~3.5 chars per token). When usage reaches 80% of the budget, the compaction pipeline triggers automatically.
Compaction applies three strategies in order, stopping as soon as the message array fits under budget:
Shorten long tool result contents in older messages. Each tool result is capped to 200 characters with a truncation marker. Fast and lossless for tool call identity.
Replace old assistant-message + tool-result pairs with a single compact system note summarizing what happened. Preserves the fact that a tool was called without keeping its full output.
Drop the oldest non-system messages entirely, replaced by a single [Earlier conversation compacted] marker. A last resort that guarantees the budget is met.
Users can also trigger compaction manually at any time with the /compact slash command, which forces the pipeline to run regardless of the current budget level.
Tool Registry — 35+ Built-in Tools
Every capability an agent has comes from tools. Tools are TypeScript classes that extend a base Tool interface, declare a JSON Schema for their inputs, and implement an execute() method. The Tool Registry (src/tools/registry.ts) collects all registered tools, converts them to OpenAI function-call format, and dispatches execution by name.
| Category | Tools | Purpose |
|---|---|---|
| File Operations | file_read · file_write · file_edit |
Read file contents, write new files, apply targeted text edits with inline diff display |
| Search | grep · glob · tool_search |
Regex search through file contents, file pattern matching, discover available tool capabilities |
| Shell | shell |
Execute any shell command — run tests, git operations, builds, arbitrary scripts |
| Web | web_search · web_fetch |
Search the web, fetch URLs — look up docs, APIs, library references |
| Task Management | task_create · task_list · task_get · task_update · task_stop · task_output |
Create and track background tasks with status, dependencies, and captured output |
| Agent Orchestration | agent_spawn · agent_create |
Spawn a sub-agent with a task message; create and persist custom agent definitions |
| Team Coordination | team_create · team_status · team_message · team_check_messages · team_task_claim |
Form agent teams, broadcast/receive mailbox messages, atomically claim tasks |
| Kanban Board | kanban (multi-action) |
Add/move/update/archive cards; add/toggle/remove sub-tasks; view full board state |
| Code Quality | lsp_diagnostics · notebook_edit · repl |
Check TypeScript/language server diagnostics, edit Jupyter notebooks, execute REPL code |
| Skills & Mode | skill_create · skill_list · enter_plan_mode · exit_plan_mode · brief_toggle |
Persist custom slash commands, control plan mode, toggle compact output |
| Misc | ask_user_question · sleep · todo_write · config |
Interactive prompts, delays, simple todo tracking, configuration inspection |
Scoped registries: Agents don't receive all tools — only the subset declared in their allowedTools list. This is enforced at registry build time in buildTeammateRegistry(), so a Reviewer agent cannot accidentally call file_write even if the user asks it to.
Five Built-in Agents
Each agent is an AgentDefinition object — a data structure declaring its name, system prompt, allowed tools, max turns, and optional model profile. The AgentRouter resolves agent names to these definitions at runtime. Agents are not classes; they are pure configuration that parameterizes the shared query loop.
How to Invoke Agents
Agents are invoked through the agent_spawn tool (from within other agents or the lead), or directly via the terminal. The system detects agent-intent keywords in your message. You can also specify an agent explicitly:
# Invoke specific agent for a task > As the coder agent, implement a rate limiter for the API endpoint # Spawn a sub-agent from the lead agent (tool call) agent_spawn({ agent: "explorer", task: "Map all imports in src/query/" })
Custom Agents
Beyond the five built-in agents, you can define your own agents with arbitrary tool sets and system prompts. Custom agents are created through the /agent slash command and persisted to disk via customAgentStore.ts — they survive across sessions and become available immediately after creation.
When you type /agent create a test runner agent that runs Jest and summarizes failures, the system uses the AgentSkill's prompt template to instruct the LLM to generate a complete AgentDefinition object and call agent_create with it. The definition is saved to ~/.custom-agents/agents.json.
// Stored structure of a custom agent (agents.json) { "name": "test-runner", "description": "Runs Jest tests and summarizes failures", "systemPrompt": "You are a test execution agent...", "allowedTools": ["shell", "file_read", "grep"], "maxTurns": 8, "mode": "sync", "modelProfile": "fast" // optional: routes to a specific model }
Custom agents optionally reference a model profile by name. This lets you route lightweight agents to cheaper/faster models (like GPT-4o Mini) while keeping complex agents on powerful models (like Claude Opus or GPT-4o).
Agent Teams — Parallel Multi-Agent Execution
The most powerful feature of Custom Agents is the ability to form agent teams — groups of specialized agents that work in parallel, each on their own task, communicating through a shared mailbox and coordinating via a task graph with dependency tracking.
How Teams Work Internally
When the lead agent calls team_create, the TeamManager creates a TeamState record with a shared Mailbox instance, a root task for the team, and one child task per teammate. Each teammate gets its own isolated AppState store — so streaming state, tool call tracking, and turn counts are completely independent per agent.
The actual concurrent execution is a single Promise.allSettled() call that runs all teammates simultaneously. Because Bun is single-threaded with an async event loop, there are no race conditions on the shared mailbox — all async operations interleave safely without locks.
Task Claiming with Dependencies
The TaskManager supports dependency chains: task A can declare that it blockedBy task B. When B completes, B is automatically removed from A's blockedBy array, making A claimable. The claim() method is atomic — it checks status, claimedBy, and blockedBy in a single synchronous operation, preventing two teammates from claiming the same task.
// Example: spawn a team from a terminal prompt > Create a team with an explorer and a coder to refactor src/query/ // Internally, the lead agent calls: team_create({ name: "query-refactor", teammates: [ { agent: "explorer", task: "Map the query module structure" }, { agent: "coder", task: "Refactor compaction.ts to reduce coupling" } ] })
Inter-Agent Mailbox
The Mailbox class (src/teams/Mailbox.ts) provides in-process pub/sub messaging between teammates in a team. It is the only shared mutable object between concurrent teammates — and since Bun's event loop is single-threaded, all operations are naturally race-condition-free.
send(from, to, content)Send a direct message to a specific teammate by ID. The message is stored and the target's subscribers are notified. Sets read: false initially.
send(from, "all", content)Broadcast to all other teammates. All subscribers except the sender receive the notification. Useful for status updates — "I've finished mapping the module."
receive(agentId)Fetch all unread messages addressed to this agent (direct or broadcast), marking them as read. Teammates call this via the team_check_messages tool.
peek(agentId)Read unread messages without marking them as read. Useful for checking if there are pending messages before deciding whether to call receive.
Message structure: Each message has an id, from, to (or "all"), content string, timestamp, and read boolean. The full history() is always accessible for debugging or audit purposes.
Slash Commands (Skills System)
Slash commands are implemented as Skills — lightweight configuration objects that define a /name, a description, and either a promptTemplate (type: "prompt") or a direct tool invocation (type: "tool"). When you type /explain my function, the skills router matches the command, expands the promptTemplate with your input substituted for {"{{input}}"}, and feeds the result to the query loop as if you had typed that expanded prompt manually.
| Command | Type | What It Does |
|---|---|---|
/explain | prompt | Generates a detailed explanation of the provided code — patterns, behavior, edge cases |
/commit | prompt | Runs git diff --cached, writes a conventional commit message for staged changes |
/status | prompt | Runs git status and recent log, gives a concise project status summary |
/find | prompt | Uses grep + glob to search the codebase for files or code matching the query |
/diff | tool | Shows an inline side-by-side diff of all uncommitted changes |
/compact | tool | Forces context compaction immediately, freeing up token budget |
/plan | prompt | Enters plan mode — agent explores and plans before making any edits |
/brief | prompt | Toggles brief/compact output mode for more concise terminal responses |
/agent | prompt | Creates a custom agent from a natural language description, persists it |
/skill | prompt | Creates a custom slash command from a natural language description, persists it |
/board | prompt | Views and manages the Kanban board — add cards, move columns, run/execute card work |
Custom Skills
You can create your own slash commands with /skill create a /lint command that runs ESLint and summarizes the errors. The system generates a SkillDefinition and calls skill_create, saving it to ~/.custom-agents/skills.json. The new /lint command is immediately available in the current and all future sessions.
Kanban Board
The Kanban board is a file-backed persistent project board stored at <dataDir>/kanban.json. It gives agents and users a shared visual representation of work items. Unlike the in-memory TaskManager (which tracks ephemeral agent tasks), the Kanban board persists across sessions and represents project-level work items.
Board Schema
Each card has a title, optional description, column (backlog | planning | in-progress | review | done), priority (low | medium | high), labels array, and a list of sub-tasks. Sub-tasks are lightweight checklist items with a done boolean. When an agent works on a card, it toggles sub-tasks as it completes each step, providing real-time progress visible to the user via /board.
Agent-Driven Card Execution
The /board run "Refactor query.ts" flow is handled by the BoardSkill prompt template. It instructs the AI to: find the card → move it to in-progress → create sub-tasks → spawn the appropriate agent with the card ID and task IDs embedded in the message → verify tasks are toggled → move the card to done. The spawned agent marks sub-tasks complete using kanban tool action toggle_task as it works.
Task Management
The TaskManager (src/tasks/TaskManager.ts) is an in-memory task graph — separate from the Kanban board. Where Kanban tracks persistent project work, TaskManager tracks ephemeral agent execution tasks that live only within a session. It is used primarily by the team system to track teammate execution state and coordinate work distribution.
Task State Machine
Tasks support dependency tracking: a task can declare it is blockedBy a list of other task IDs. When a blocking task transitions to completed, the TaskManager automatically removes it from all blocked tasks' blockedBy arrays, making those tasks eligible for claiming. The claim() method is a synchronous atomic check — if the task is already claimed or blocked, it returns null.
Parent-child hierarchy: When a team is created, a root task is created for the team, and child tasks are created for each teammate. This lets TaskManager.list({ parentId }) quickly retrieve all tasks belonging to a team, and the team's completion is derived from transitioning the root task.
Memory & Session Persistence
Three-Scope Memory Store
The MemoryStore (src/memory/index.ts) provides file-backed key-value storage across three scopes that differ in lifetime and visibility:
Shared across all sessions in the same project directory. Persists until explicitly deleted. Ideal for storing project-specific facts (architecture decisions, known APIs).
~/.custom-agents/memory/project/
Shared across all projects for this user. Persists globally. Good for personal preferences — code style, preferred patterns, shortcuts.
~/.custom-agents/memory/user/
Scoped to the current session ID. Cleaned up when the session ends. Useful for temporary scratchpad storage within a single working session.
~/.custom-agents/memory/session/{id}/
Memory is injected into the system prompt via MemoryStore.buildContext(), which assembles a --- Persistent Memory --- section appended to the agent's system prompt before each query loop. Agents can read and write memory using tools, giving them a form of long-term learning across conversations.
Session Persistence (Transcript)
The SessionPersistence module (src/persistence/SessionPersistence.ts) saves the full conversation transcript — every message, including tool calls and results — to a JSON file on disk. When you start a new session, you can resume a previous one, and the entire history is reloaded into the query loop's message array.
Multi-Model Orchestration
By default, all agents use the global model configured in ~/.custom-agents/config.env. But you can assign different LLM models to different agents through model profiles stored in .custom-agents/models.json. This lets you optimize for cost and capability simultaneously: route lightweight explorer tasks to a cheap fast model, while complex coding tasks go to a premium model.
// .custom-agents/models.json { "version": 1, "profiles": [ { "name": "fast", "model": "openai/gpt-4o-mini", "apiKey": "sk-or-v1-...", "baseUrl": "https://openrouter.ai/api/v1" }, { "name": "reasoning", "model": "anthropic/claude-opus-4", "apiKey": "sk-or-v1-...", "baseUrl": "https://openrouter.ai/api/v1" } ] }
The resolveModelConfig() function in src/models/resolveModelConfig.ts is called for each teammate before its query loop starts. It checks the agent definition's optional modelProfile field, looks it up in the ModelProfileStore, and returns the resolved model, apiKey, and baseUrl. If no profile is set, the global config is used — so existing setups require no changes.
Plugin System & Hook Events
Hooks — Lifecycle Events
The HookManager (src/hooks/index.ts) is a typed async event bus. Every major lifecycle event emits a hook, and any module (including plugins) can subscribe to intercept or react to those events without modifying core code.
| Hook Event | When It Fires | Payload |
|---|---|---|
query:before | Before the first LLM turn in a query | { messages } |
query:after | After query loop completes or errors | { messages, turnCount, error? } |
message:assistant | Each time the assistant produces a message | { message } |
team:start | When a team begins execution | { teamId, name } |
team:complete | When all teammates finish | { teamId, status, duration } |
team:teammate:start | When an individual teammate begins | { teamId, teammateId, agentName } |
team:teammate:end | When a teammate finishes or fails | { status, output } |
Plugin System
The plugins/ module provides the extensibility layer. A plugin can register additional tools (extending the tool registry), subscribe to hooks (adding side effects like logging or notifications), or add new skills (custom slash commands). Plugins are loaded during initialization before the REPL starts, making all their additions available immediately.
Configuration
Configuration is read from two sources in priority order: a project-level .env file in the current working directory (highest priority), falling back to the global config at ~/.custom-agents/config.env. This lets you override the model or API key per project without touching global settings.
# ~/.custom-agents/config.env OPENAI_API_KEY=sk-your-key-here OPENAI_BASE_URL=https://openrouter.ai/api/v1 MODEL=openrouter/auto LOG_LEVEL=info # debug | info | warn | error MAX_TURNS=20 # global default; per-agent overrides apply CONTEXT_BUDGET=120000 # tokens before compaction triggers
| Variable | Description | Default |
|---|---|---|
OPENAI_API_KEY | API key for your LLM provider | — |
OPENAI_BASE_URL | API endpoint — any OpenAI-compatible URL | https://openrouter.ai/api/v1 |
MODEL | Model identifier string for your provider | openrouter/auto |
LOG_LEVEL | Logging verbosity | info |
MAX_TURNS | Global max turns per query loop | 20 |
CONTEXT_BUDGET | Token budget before compaction | 120000 |
Supported LLM Providers
Access 100+ models through a single API. Set OPENAI_BASE_URL=https://openrouter.ai/api/v1. Recommended for flexibility.
Direct connection to GPT-4o, GPT-4o Mini, and other OpenAI models.
Fully local, offline operation. No data leaves your machine. Set base URL to localhost.
Installation & Development
One-Liner Install
# Install (or update) curl -fsSL https://raw.githubusercontent.com/iabhisekbosepm/custom_agent/main/install.sh | bash # The installer will: # 1. Install Bun if not present # 2. Clone source to ~/.custom-agents-cli/ # 3. Prompt for OPENAI_API_KEY, OPENAI_BASE_URL, MODEL # 4. Register the 'custom-agents' command globally # Then use it anywhere cd ~/any-project custom-agents
Development Setup
git clone https://github.com/iabhisekbosepm/custom_agent.git cd custom_agent cp .env.example .env # Add your API key bun install # Install dependencies bun run src/index.ts # Start bun --watch run src/index.ts # Dev mode with hot reload bun test # Run tests bun x tsc --noEmit # Type check
Tech Stack
Replaces Node.js. Faster startup, native TypeScript support, built-in Bun.file() and Bun.write() APIs. Test runner included. The single-threaded async event loop makes in-memory concurrency safe without locks.
Ink renders React components as terminal output using flexbox layout. State updates (e.g., a streaming token) trigger a React re-render which updates the terminal in-place — just like a browser SPA, but in your terminal.
Full strict mode. All messages, tool inputs/outputs, agent definitions, and state objects are typed. Zod is used for runtime validation of tool inputs before execution.
The entire system is built around the OpenAI /v1/chat/completions streaming API, function-calling schema, and tool result messages. Any provider that implements this interface works — OpenRouter, Ollama, LM Studio, etc.
Project Structure (annotated)
src/ ├── agents/ # AgentDefinition type, AgentRouter, 5 builtin agents, customAgentStore ├── components/ # Ink terminal UI: message bubbles, streaming, diffs, team panel ├── entrypoints/ # CLI entry point, initialization, dependency wiring ├── hooks/ # HookManager: typed async lifecycle event bus ├── kanban/ # KanbanStore: file-backed board, cards, sub-tasks ├── memory/ # MemoryStore: 3-scope key-value file storage ├── models/ # ModelProfileStore: per-agent LLM config, resolveModelConfig ├── persistence/ # SessionPersistence: save/load conversation transcripts ├── plugins/ # Extensibility: load tools/hooks/skills from plugins ├── query/ # Core engine: runQueryLoop, streamOpenAI, compaction ├── screens/ # Terminal screens: REPL input, session manager ├── services/ # Background services startup/shutdown ├── skills/ # SkillDefinition: 11 builtin slash commands, customSkillStore ├── state/ # AppStateStore: reactive state; store.ts: createStore() ├── tasks/ # Task.ts: state machine; TaskManager: graph, deps, claiming ├── teams/ # TeamManager, Mailbox, TeamTypes, buildTeammateRegistry ├── tools/ # 35+ tool implementations (one dir per tool), registry, orchestration ├── types/ # Shared types: messages, config, etc. └── utils/ # logger, diff renderer, env loader, id generator, shutdown