CamelAGI Agent System
Overview
The CamelAGI agent system uses a dual-path architecture that routes execution to one of two backends depending on the model being used:
- Claude Agent SDK path (
agent-sdk.ts) – Full-featured agent with built-in tools, thinking/extended thinking, subagent spawning, MCP server integration, and approval gating. Used for Claude models. - OpenAI-compatible path (
agent-openai.ts) – Streaming chat completions via the OpenAI SDK. Works with any provider that exposes an OpenAI-compatible API (OpenAI, Anthropic compatibility layer, local models, etc.). Used for all non-Claude models.
Both paths share a common interface (AgentOpts, RunResult, AgentEvent) defined in src/agent/types.ts, so the rest of the system (TUI, gateway, Telegram channel) is backend-agnostic.
File Map
| File | Purpose |
|---|---|
src/agent.ts |
Entry point – model detection, hooks, routing |
src/agent/agent-sdk.ts |
Claude SDK path (tools, thinking, subagents) |
src/agent/agent-openai.ts |
OpenAI-compatible streaming path |
src/agent/types.ts |
Shared types: AgentOpts, RunResult, AgentEvent |
src/agent/tool-adapter.ts |
Converts ToolDef to Claude SDK tool() calls |
src/extensions/hooks.ts |
Lifecycle hook runner |
src/extensions/approvals.ts |
Approval gating for dangerous tools |
Path Selection
The entry point runAgent() in src/agent.ts decides which backend to use with a simple check:
function isClaudeModel(model: string): boolean {
return model.startsWith("claude-") || model.includes("/claude-");
}
const useSdk = isClaudeModel(model) && !opts?.baseUrl;
Rules:
- If the model name starts with
"claude-"or contains"/claude-"and no custombaseUrlis set, the Claude SDK path is used. - Everything else (GPT-4, Llama, Mistral, Gemini, or Claude with a custom base URL) goes through the OpenAI-compatible path.
- Setting a custom
baseUrlforces the OpenAI path even for Claude models, which is useful for proxies or alternative endpoints.
Think-Act Loop
Claude SDK Path
The SDK path delegates the think-act loop to the Claude Agent SDK’s query() function. It operates as an agentic loop internally:
- The model receives the prompt and system instructions.
- It generates a response, which may include tool calls.
- Tools are executed (with pre/post hooks and approval checks).
- Results are fed back to the model.
- The loop continues until the model produces a final text response or
maxTurnsis reached.
The loop is configured via:
const q = query({
prompt: userMessage,
options: {
model,
systemPrompt: effectiveSystemPrompt,
allowedTools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep",
"WebSearch", "WebFetch", "Agent"],
mcpServers: { camelagi: mcpServer },
maxTurns: opts?.maxTurns ?? 25, // DEFAULT_MAX_TURNS
permissionMode: "bypassPermissions",
thinking,
// ...
},
});
The result is consumed as an async iterator, yielding result, system, and stream_event messages.
OpenAI-Compatible Path
The OpenAI path implements a full tool loop using OpenAI’s function-calling API:
- History and the new user message are assembled into a messages array.
- Custom tools (
memory_search,memory_get,apply_patch,cron) are converted to OpenAI function-calling format usingadaptToolDefToOpenAI()(Zod 4’s native.toJSONSchema()). - A streaming
chat.completions.create()call is made withtoolsparameter. - Streamed deltas are accumulated for both content and tool calls (arguments arrive as chunks that are concatenated).
- If the response contains
tool_calls, each tool is executed with pre/post hooks (before_tool,after_tool), results are appended astoolmessages, and the loop continues. - The loop runs until the model returns a response with no tool calls, or
maxTurnsis reached. - If the last message is a tool result and no final text was produced, one more call is made without tools to get a summary.
This path does not support thinking, subagents, or approval gating – but it provides full tool execution, hooks, and streaming for any OpenAI-compatible provider (OpenAI, DeepSeek, Ollama, OpenRouter, etc.).
Agent Events
All events are emitted via the onEvent callback in AgentOpts. The TUI, gateway, and Telegram channel consume these to render output.
| Event Type | Fields | Description | Path |
|---|---|---|---|
tool_call |
id, name, args |
A tool is about to be executed | Both |
tool_result |
id, name, preview |
A tool finished; preview is first 150 chars of output |
Both |
chunk |
text |
Final complete response text | Both |
stream_text |
text |
Incremental text delta (streaming) | Both |
thinking |
state: "start" | "end" |
Extended thinking block boundary | SDK |
thinking_delta |
text |
Incremental thinking text delta | SDK |
init |
sessionId |
SDK session initialized with an ID | SDK |
subagent_start |
agentId, taskId? |
A subagent task was spawned | SDK |
subagent_progress |
agentId, toolCount?, duration? |
Subagent progress update | SDK |
subagent_done |
agentId, toolUseId? |
Subagent task completed | SDK |
usage |
inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens? |
Token usage report | Both |
approval_request |
id, toolName, preview |
Tool call requires user approval | SDK |
approval_resolved |
id, decision |
Approval decision was made | SDK |
Event Type Definition
export type AgentEvent =
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; name: string; preview: string }
| { type: "chunk"; text: string }
| { type: "stream_text"; text: string }
| { type: "thinking"; state: "start" | "end" }
| { type: "thinking_delta"; text: string }
| { type: "init"; sessionId: string }
| { type: "subagent_start"; agentId: string; taskId?: string }
| { type: "subagent_progress"; agentId: string; toolCount?: number; duration?: number }
| { type: "subagent_done"; agentId: string; toolUseId?: string }
| { type: "usage"; inputTokens: number; outputTokens: number;
cacheReadTokens?: number; cacheWriteTokens?: number }
| { type: "approval_request"; id: string; toolName: string; preview: string }
| { type: "approval_resolved"; id: string; decision: string };
Tool Policy (Allow/Deny Filtering)
The toolPolicy option lets you restrict which tools the agent can use:
interface AgentOpts {
toolPolicy?: { allow: string[]; deny: string[] };
// ...
}
How It Works
- SDK path: The
denylist is passed asdisallowedToolsto the SDK’squery()options. The SDK itself enforces the restriction. - OpenAI path: All custom tools are registered via
adaptToolDefToOpenAI(). The tool policydenylist is not yet enforced on this path (planned improvement).
Configuration Example
# In ~/.camelagi/config.yaml
tools:
allow: [] # empty = allow all (default)
deny:
- Bash # prevent shell execution
- Write # prevent file writes
Built-in Tools (SDK Path)
The SDK path exposes these built-in tools:
| Tool | Description |
|---|---|
Read |
Read file contents |
Write |
Write/create files |
Edit |
Edit existing files |
Bash |
Execute shell commands |
Glob |
File pattern matching |
Grep |
Content search (ripgrep) |
WebSearch |
Web search |
WebFetch |
Fetch web pages |
Agent |
Spawn a subagent |
Additionally, custom tools are registered via an MCP server:
| Custom Tool | Description |
|---|---|
memory_search |
Full-text search across memory files |
memory_get |
Read a specific memory file |
patch |
Apply unified diff patches |
cron |
Schedule recurring tasks |
Hooks Integration
Lifecycle hooks are shell scripts or JS handlers placed in ~/.camelagi/hooks/. They fire at four points in the agent lifecycle.
Hook Points
| Hook Point | When It Fires | Context Variables |
|---|---|---|
before_prompt |
Before the user message is sent to the model | CAMELAGI_HOOK_SESSION, CAMELAGI_HOOK_MESSAGE |
after_response |
After the model returns a final response | CAMELAGI_HOOK_SESSION, CAMELAGI_HOOK_RESPONSE |
before_tool |
Before a tool is executed (both paths) | CAMELAGI_HOOK_SESSION, CAMELAGI_HOOK_TOOL, CAMELAGI_HOOK_TOOL_ARGS |
after_tool |
After a tool finishes (both paths) | CAMELAGI_HOOK_SESSION, CAMELAGI_HOOK_TOOL, CAMELAGI_HOOK_TOOL_RESULT |
Hook Execution
before_promptandafter_responserun in the mainrunAgent()entry point (both paths).before_toolandafter_toolrun in the SDK path’sPreToolUse/PostToolUsecallbacks, and in the OpenAI path’s tool loop before/after each tool execution.- Hooks are executed synchronously via
execSyncwith a 10-second timeout (HOOK_TIMEOUT_MS). - Hook failures are logged to stderr but do not abort the agent.
- Hooks are only active when
opts.hooksEnabledistrue.
File Naming Convention
~/.camelagi/hooks/{point}.{name}.sh
~/.camelagi/hooks/{point}.{name}.js
Examples:
before_tool.log.sh # log every tool call
after_response.notify.sh # send a notification after response
before_prompt.validate.js # validate user input
Configuration
# In ~/.camelagi/config.yaml
hooks:
enabled: true # default: false
Abort and Timeout Handling
AbortSignal
Both paths support cancellation via AbortSignal:
interface AgentOpts {
signal?: AbortSignal;
timeoutMs?: number;
// ...
}
SDK path: The signal is bridged to an AbortController that is passed to the SDK:
const abortController = opts?.signal ? new AbortController() : undefined;
if (opts?.signal && abortController) {
if (opts.signal.aborted) abortController.abort();
else opts.signal.addEventListener("abort", () => abortController.abort(), { once: true });
}
OpenAI path: The signal is checked before the request and after every streamed chunk:
if (opts?.signal?.aborted) throw new Error("Aborted");
// ... inside the stream loop:
if (opts?.signal?.aborted) throw new Error("Aborted");
Timeout Composition
The caller typically creates a combined signal using AbortSignal.any():
const signals = [userAbortSignal];
if (timeoutMs) signals.push(AbortSignal.timeout(timeoutMs));
const combined = AbortSignal.any(signals);
This allows both user-initiated abort (e.g., pressing Escape in TUI) and timeout-based abort to work through the same mechanism.
Thinking and Effort Modes
Extended thinking lets Claude show its reasoning process before producing a final answer.
Thinking Configuration
interface AgentOpts {
thinking?: string; // "off" | any other value enables adaptive thinking
effort?: "low" | "medium" | "high" | "max";
// ...
}
How thinking is applied (SDK path only):
const thinking = opts?.thinking && opts.thinking !== "off"
? { type: "adaptive" as const }
: { type: "disabled" as const };
- When
thinkingis set to anything other than"off", adaptive thinking is enabled. The model decides when and how much to think. - When
thinkingis"off"or unset, thinking is disabled entirely.
Effort mode controls how much computation the model uses:
| Effort | Behavior |
|---|---|
low |
Quick, minimal reasoning |
medium |
Balanced (default when not specified) |
high |
Thorough reasoning |
max |
Maximum effort, most thorough |
Thinking Events
When thinking is active, the TUI receives these events to display thinking state:
thinking { state: "start" } -- thinking block begins
thinking_delta { text: "..." } -- incremental thinking text
thinking { state: "end" } -- thinking block ends
stream_text { text: "..." } -- final answer text
Configuration Example
# In ~/.camelagi/config.yaml
thinking: "adaptive" # enable extended thinking
effort: "high" # use high effort mode
Subagent Spawning (SDK Path Only)
The Claude SDK path supports spawning subagents via the built-in Agent tool. Subagents are independent agent instances that execute a subtask and return results.
How It Works
- The main agent calls the
Agenttool with a prompt. - The SDK spawns a child agent with its own tool access and turn budget.
- The parent agent receives progress updates and the final result.
Subagent Events
Three events track subagent lifecycle:
| Event | Fields | Description |
|---|---|---|
subagent_start |
agentId, taskId? |
Subagent task created |
subagent_progress |
agentId, toolCount?, duration? |
Periodic progress (tool count, elapsed time) |
subagent_done |
agentId, toolUseId? |
Subagent completed its task |
These are derived from SDK system messages:
if (sysMsg.subtype === "task_started") {
emit({ type: "subagent_start", agentId: sysMsg.agent_id ?? "subagent", taskId: sysMsg.task_id });
}
Scoped Memory
Each subagent gets its own scoped memory directory. When agentId is provided, memory tools read/write to an agent-specific subdirectory:
function getToolDefs(agentId?: string): ToolDef[] {
const memRoot = agentMemoryDir(agentId);
const scopedMemory = agentId
? createScopedMemoryTools(memRoot)
: { search: memorySearchTool, get: memoryGetTool };
return [scopedMemory.search, scopedMemory.get, patchTool, cronTool];
}
Approval Integration
The approval system gates dangerous tool calls behind user confirmation. It operates within the SDK path’s PreToolUse hook.
Approval Modes
| Mode | Behavior |
|---|---|
off |
No approval required (default) |
smart |
Auto-approve read-only tools; ask for writes, exec, and agents |
always |
Ask for every tool call |
Read-Only Tools (Auto-Approved in Smart Mode)
Read, Glob, Grep, WebSearch, WebFetch, memory_search, memory_get
Approval Flow
Tool call triggered
|
v
checkApproval(toolName, args, mode, allowlist)
|
|-- mode is "off"? --> execute immediately
|-- in allowlist? --> execute immediately
|-- mode is "smart" and tool is read-only? --> execute immediately
|
v
Emit approval_request event
|
v
waitForDecision(id, timeoutMs, fallback)
|
|-- "allow-once" --> execute this time
|-- "allow-always" --> execute + add to allowlist
|-- "deny" --> block with reason
|-- timeout --> use fallback (deny or allow)
Approval Forwarding
When no direct approval channel is available (e.g., headless gateway mode), approvals can be forwarded to a Telegram user:
approvals?: {
mode: ApprovalMode;
allowlist: string[];
timeoutSeconds: number;
fallback: "deny" | "allow";
forwardTo?: number; // Telegram user ID
};
Allowlist Patterns
The allowlist supports glob patterns for fine-grained control:
| Pattern | Matches |
|---|---|
Read |
All Read tool calls |
Bash:git * |
Bash commands starting with git |
Write:/path/to/file.ts |
Writing to a specific file |
Edit:/src/* |
Editing any file under /src/ |
apply_patch:* |
All patch operations |
Configuration Example
# In ~/.camelagi/config.yaml
approvals:
mode: smart
allowlist:
- Read
- Glob
- Grep
- "Bash:git *"
- "Bash:npm *"
timeoutSeconds: 120
fallback: deny
forwardTo: 123456789 # Telegram user ID (optional)
Tool Adapter
The tool-adapter.ts module bridges CamelAGI’s ToolDef interface to both the Claude Agent SDK and OpenAI function-calling formats:
Claude SDK adapter (adaptToolDef):
export function adaptToolDef(def: ToolDef) {
return tool(
def.name,
def.description,
def.schema.shape, // Zod shape -> SDK params
async (args) => {
const result = await def.execute(args as Record<string, unknown>);
return { content: [{ type: "text" as const, text: result }] };
},
);
}
OpenAI adapter (adaptToolDefToOpenAI):
export function adaptToolDefToOpenAI(def: ToolDef): OpenAITool {
const jsonSchema = def.schema.toJSONSchema(); // Zod 4 native
delete jsonSchema.$schema;
return {
type: "function",
function: { name: def.name, description: def.description, parameters: jsonSchema },
};
}
Both adapters eliminate per-tool boilerplate. Any ToolDef is automatically converted to the appropriate format. The OpenAI adapter uses Zod 4’s built-in .toJSONSchema() method for reliable schema conversion.
Configuration Reference
AgentOpts Interface
export interface AgentOpts {
maxTurns?: number; // Max think-act iterations (default: 25)
signal?: AbortSignal; // Cancellation signal
onEvent?: (event: AgentEvent) => void; // Event callback
timeoutMs?: number; // Timeout in milliseconds
toolPolicy?: { // Tool allow/deny lists
allow: string[];
deny: string[];
};
hooksEnabled?: boolean; // Enable lifecycle hooks
sessionId?: string; // Session ID for hooks/usage tracking
thinking?: string; // "off" to disable, anything else enables adaptive
effort?: "low" | "medium" | "high" | "max"; // Reasoning effort level
resumeSessionId?: string; // Resume a previous SDK session
maxBudgetUsd?: number; // Max spend for this run (SDK only)
agentId?: string; // Agent ID for scoped memory
provider?: string; // Provider hint ("openai", "anthropic")
baseUrl?: string; // Custom API base URL
approvals?: { // Approval gating config
mode: ApprovalMode; // "off" | "smart" | "always"
allowlist: string[]; // Pre-approved tool patterns
timeoutSeconds: number; // How long to wait for a decision
fallback: "deny" | "allow"; // Default if timeout
forwardTo?: number; // Telegram user ID for forwarding
};
}
RunResult Interface
export interface RunResult {
response: string; // Final text response
newMessages: Message[]; // User + assistant messages to append to history
usage: TokenUsage | null; // Token usage (may be null)
sessionId?: string; // SDK session ID (for resume)
}
YAML Configuration Examples
Minimal (defaults):
model: claude-sonnet-4-20250514
apiKey: sk-ant-...
Full-featured:
model: claude-sonnet-4-20250514
apiKey: sk-ant-...
maxTurns: 30
timeoutSeconds: 300
thinking: adaptive
effort: high
tools:
allow: []
deny:
- Agent # disable subagent spawning
hooks:
enabled: true
approvals:
mode: smart
allowlist:
- Read
- Glob
- Grep
- "Bash:git *"
timeoutSeconds: 120
fallback: deny
OpenAI-compatible provider:
model: gpt-4o
apiKey: sk-...
provider: openai
# Tools (memory, patch, cron) work via function-calling
# No thinking or subagents
Custom base URL (forces OpenAI path even for Claude models):
model: claude-sonnet-4-20250514
apiKey: sk-ant-...
baseUrl: https://my-proxy.example.com/v1
# Uses OpenAI-compatible path because baseUrl is set
# Custom tools available via function-calling
Session Resume (SDK Path Only)
The SDK path supports resuming a previous session. When resumeSessionId is provided:
- History is not prepended to the prompt (the SDK already has it).
- The SDK resumes from the previous session state.
When not resuming, history is prepended to the user prompt wrapped in structured tags (keeping the system prompt clean and constant-size):
if (!opts?.resumeSessionId && history.length > 0) {
const historyText = history.map((m) => `[${m.role}]: ${m.content}`).join("\n\n");
effectivePrompt = `<previous_conversation>\n${historyText}\n</previous_conversation>\n\n${userMessage}`;
}
This approach avoids bloating the system prompt with conversation history, which would waste tokens on every turn and grow quadratically.
The returned RunResult.sessionId can be stored and passed as resumeSessionId in a subsequent call.
Environment Notes
- The agent entry point deletes
process.env.CLAUDECODEto prevent “nested session” errors when running inside Claude Code. - The SDK path sets
permissionMode: "bypassPermissions"andallowDangerouslySkipPermissions: truebecause CamelAGI handles permissions through its own approval system. - The working directory is set to
process.cwd()for the SDK, so file tools operate relative to where CamelAGI was launched. - Token usage is recorded per-session via
recordUsage()for both paths when asessionIdis available.