# Claude-CLI Engine Feasibility Report (v2)

> Deep research on adding `type: "claude-cli"` engine alongside `"openai"` in VeilCli.

---

## 1. Executive Summary

| Concern | Verdict |
|---------|---------|
| Is it technically possible? | **YES** — the SDK covers ~85% of VeilCli's features with direct equivalents or workarounds |
| System prompt injection? | **YES** — `systemPrompt: { type: "preset", preset: "claude_code", append: "..." }` ADDS to Claude's prompt |
| Custom tools? | **YES** — `tool()` + `createSdkMcpServer()` creates **in-process MCP** (no separate process needed) |
| Hooks (PreToolUse/PostToolUse)? | **YES** — SDK has the same hook events as VeilCli, plus 12 more |
| Session fork/reset? | **YES** — `resume` + `forkSession: true` + `resumeSessionAt` for fork; new `query()` for reset |
| Message injection mid-session? | **YES** — prompt queue + `streamInput()` + `shouldQuery: false` for silent injection |
| Multi-provider fallback? | **CREATIVE WORKAROUND** — degrade gracefully by replaying recorded messages |
| Can it be scoped to specific files? | **YES** — 3-4 new files in `engines/`, 4 existing files with ~30 lines each |
| Drop-in session redirect? | **PARTIAL** — new sessions yes; existing message history needs manual replay |

---

## 2. SDK Capabilities (Verified from Official Docs)

### 2.1 System Prompt — THREE Modes

```typescript
// Mode 1: Replace entirely (lose Claude Code's built-in instructions)
systemPrompt: "You are a security reviewer..."

// Mode 2: APPEND to Claude Code's full prompt (RECOMMENDED)
systemPrompt: {
  type: "preset",
  preset: "claude_code",
  append: "Your VeilCli 7-layer system prompt goes here...",
  excludeDynamicSections: true  // better prompt cache reuse
}

// Mode 3: Default (minimal prompt, no Claude Code instructions)
// omit systemPrompt entirely
```

**Mode 2 is the key.** VeilCli's `assembleSystemPrompt()` output can be concatenated and injected via `append`. Claude keeps its own system prompt (tools, safety, coding guidelines) AND your custom content is added. Both work simultaneously.

`settingSources: ["user", "project", "local"]` controls which `CLAUDE.md` files load. Set to `[]` to disable filesystem settings entirely if you want full control.

### 2.2 Hooks — Full Parity Plus More

The SDK supports **17 hook events** (VeilCli only has PreToolUse/PostToolUse):

| Hook Event | VeilCli equivalent | Notes |
|------------|---------------------|-------|
| `PreToolUse` | `PreToolUse` hook | Can inspect/modify/block tool calls |
| `PostToolUse` | `PostToolUse` hook | Can inject `additionalContext` |
| `PostToolUseFailure` | — | After tool execution fails |
| `UserPromptSubmit` | — | Before prompt is sent |
| `Stop` | — | Agent stops |
| `PreCompact` | — | Before compaction (you can observe/modify) |
| `SubagentStart/Stop` | — | Subagent lifecycle |
| `SessionStart/End` | — | Session lifecycle |
| `TaskCompleted` | — | Task completion |
| + 7 more | — | ConfigChange, WorktreeCreate, etc. |

Hook callback signature:
```typescript
type HookCallback = (
  input: HookInput,         // { tool_name, tool_input, tool_use_id, session_id, cwd, ... }
  toolUseID: string | undefined,
  options: { signal: AbortSignal }
) => Promise<HookJSONOutput>;

// PreToolUse can:
// - Block: { hookSpecificOutput: { permissionDecision: "deny", permissionDecisionReason: "..." } }
// - Modify input: { hookSpecificOutput: { updatedInput: { command: "..." } } }
// - Inject context: { systemMessage: "Remember to check for..." }
// - Approve: { hookSpecificOutput: { permissionDecision: "allow" } }
```

**This means VeilCli's PreToolUse/PostToolUse hooks can be directly ported.** The SDK hooks even support `systemMessage` injection — letting you inject guidance into the conversation at hook time.

### 2.3 Session Fork, Reset, and Resume

```typescript
// Resume existing session
query({ prompt, options: { resume: sessionId } })

// Fork from specific message
query({ prompt, options: {
  resume: sessionId,
  resumeSessionAt: messageUUID,  // fork point
  sessionId: newSessionId,       // new session gets this ID
  forkSession: true
}})

// "Reset" = start new session in same cwd
query({ prompt, options: { cwd: sameCwd } })  // fresh session, same project

// Ephemeral (no persistence)
query({ prompt, options: { persistSession: false } })

// Continue most recent session
query({ prompt, options: { continue: true, cwd: projectPath } })
```

**Session management functions:**
```typescript
listSessions({ dir?, limit? })          // List all saved sessions
getSessionMessages(sessionId, { dir? }) // Get full message history
getSessionInfo(sessionId, { dir? })     // Get session metadata
renameSession(sessionId, title)         // Rename
deleteSession(sessionId)               // Delete from disk
```

VeilCli can store the Claude session ID in `external_session_id` and use these functions to fully manage the lifecycle. Fork maps to VeilCli's fork (copy messages up to a point into new session). Reset maps to creating a new `query()`.

### 2.4 Message Injection — Multiple Approaches

**Approach 1: Prompt Queue (between turns)**
```typescript
const queue = new PromptQueue();
const runtime = query({ prompt: queue, options: { ... } });

// Push messages at any time — queued, processed after current turn
queue.push({
  type: "user",
  session_id: sessionId,
  parent_tool_use_id: null,
  message: { role: "user", content: "injected context from another agent" }
});
```

**Approach 2: `shouldQuery: false` (silent injection, no assistant turn)**
```typescript
queue.push({
  type: "user",
  session_id: sessionId,
  parent_tool_use_id: null,
  shouldQuery: false,  // Appends to transcript WITHOUT triggering assistant response
  message: { role: "user", content: "[System: Agent X completed task Y]" }
});
```

This is the equivalent of VeilCli's `drainNonFollowup` — inject context silently. The next real user message will trigger the assistant, and it will have this context available.

**Approach 3: `streamInput()` (attach new input stream mid-session)**
```typescript
async function* moreMessages() {
  yield { type: "user", message: { role: "user", content: "..." } };
}
await runtime.streamInput(moreMessages());
```

**Approach 4: Interrupt + inject (force mid-turn injection)**
```typescript
await runtime.interrupt();  // Stop current turn
queue.push(urgentMessage);  // Inject immediately
// Agent picks up from here
```

### 2.5 Custom Tools — In-Process MCP (No Separate Process!)

The SDK has `tool()` and `createSdkMcpServer()` for **in-process MCP servers**:

```typescript
import { tool, createSdkMcpServer, query } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

// Define a custom tool with Zod schema
const memoryWriteTool = tool(
  "memory_write",
  "Write to agent memory",
  { scope: z.string(), content: z.string() },
  async (args) => {
    writeMemory({ cwd, agentName, scope: args.scope, content: args.content });
    return { content: [{ type: "text", text: "Memory written." }] };
  }
);

// Bundle into an in-process MCP server (no subprocess, no HTTP!)
const veilTools = createSdkMcpServer({
  name: "veil_tools",
  version: "1.0.0",
  tools: [memoryWriteTool, todoWriteTool, todoReadTool, logWriteTool, ...]
});

// Pass directly to query — runs in your Node.js process
const runtime = query({
  prompt: promptQueue,
  options: {
    mcpServers: { "veil_tools": veilTools },
    allowedTools: ["Read", "Write", "Bash", "mcp__veil_tools__*"],
  }
});
```

**This is a game changer.** No separate stdio process, no HTTP endpoint, no port coordination. The MCP server runs in the same Node.js process as VeilCli. Custom tools have direct access to the database, event bus, agent message queue — everything.

**Tool naming in Claude CLI:** MCP tools appear as `mcp__<server-name>__<tool-name>` (double underscore). So `mcp__veil_tools__memory_write`, `mcp__veil_tools__todo_read`, etc.

### 2.6 `setMcpServers()` — Add/Remove Tools Mid-Session

```typescript
// Add new MCP server mid-session
await runtime.setMcpServers({
  "new-tools": { type: "stdio", command: "node", args: ["server.js"] }
});

// Enable/disable specific server
await runtime.toggleMcpServer("veil_tools", false);

// Reconnect after error
await runtime.reconnectMcpServer("veil_tools");
```

### 2.7 Permission System — Full Parity

Evaluation order: `hooks → disallowedTools → permissionMode → allowedTools → canUseTool`

```typescript
// canUseTool callback — programmatic permission control
canUseTool: (toolName, toolInput, options) => {
  // options.toolUseID, options.agentID, options.suggestions, options.signal
  
  // Can implement VeilCli's checkPermission() logic here:
  if (toolName === 'Bash' && toolInput.command?.includes('rm -rf')) {
    return { behavior: "deny", message: "Dangerous command blocked" };
  }
  if (toolName === 'AskUserQuestion') {
    // Handle clarifying questions from Claude
    return promptUserViaWebSocket(toolInput);
  }
  return { behavior: "allow" };
}
```

The `canUseTool` callback also handles `AskUserQuestion` — when Claude wants to ask the user a clarifying question, it comes through this same callback.

### 2.8 Mid-Session Mutations

| What | Method | Works? |
|------|--------|--------|
| Model | `runtime.setModel("claude-sonnet-4-6")` | YES |
| Permission mode | `runtime.setPermissionMode("acceptEdits")` | YES |
| Thinking budget | `runtime.setMaxThinkingTokens(10000)` | YES |
| MCP servers | `runtime.setMcpServers({...})` | YES |
| Toggle MCP server | `runtime.toggleMcpServer(name, bool)` | YES |
| Stop specific task | `runtime.stopTask(taskId)` | YES |
| Inject messages | `runtime.streamInput(stream)` | YES |
| Tools/allowedTools | — | NO (set at creation) |
| System prompt | — | NO (set at creation) |
| Effort level | — | NO (set at creation) |

---

## 3. Feature-by-Feature Mapping

### Features with FULL SDK Equivalents

| VeilCli Feature | SDK API | Notes |
|-------------------|---------|-------|
| Agentic loop | Subprocess internal | You observe events, don't drive the loop |
| PreToolUse/PostToolUse hooks | `hooks` option | Richer — 17 event types, matchers, `systemMessage` injection |
| Permission system | `canUseTool` + `allowedTools`/`disallowedTools` | Same or better granularity |
| Streaming text | `includePartialMessages: true` | `stream_event` with `text_delta`, `thinking_delta` |
| Token/cost tracking | `result.usage` + `result.total_cost_usd` | Richer fields |
| Session resume | `resume` option | Direct equivalent |
| Session fork | `forkSession: true` + `resumeSessionAt` | Fork from any message |
| Cancellation/interrupt | `interrupt()` + `abortController` | Direct equivalents |
| LLM error retry | Subprocess internal | Handled transparently |
| Event bus | `for await (const msg of runtime)` | The stream IS the event source |
| Rate limiting | `rate_limit_event` messages | Richer than current |
| Thinking/reasoning | `thinking` option | Direct equivalent |
| Model switching | `runtime.setModel()` | Mid-session |

### Features with PARTIAL SDK Equivalents + Workarounds

| VeilCli Feature | SDK Workaround | Quality |
|-------------------|---------------|---------|
| **System prompt (7-layer)** | `systemPrompt: { preset: "claude_code", append: assembledPrompt }` | **90%** — Claude keeps its prompt AND yours is appended. Only loss: no mid-loop reminder injection |
| **Custom tools** | `tool()` + `createSdkMcpServer()` (in-process) | **95%** — runs in same process, full access to DB/state. Tool naming differs (`mcp__veil_tools__name`) |
| **Message injection** | Prompt queue + `shouldQuery: false` + `streamInput()` | **80%** — between-turn injection works; mid-turn needs interrupt |
| **Context compaction** | Subprocess handles + `PreCompact` hook to observe | **70%** — auto works, but no custom model/prompt/strategy. PreCompact hook lets you observe |
| **Token budget** | `maxTurns` + `maxBudgetUsd` | **70%** — no time limit, no wait-on-exhaust |
| **Multi-mode** | Different `query()` configs per mode | **80%** — chat/task natural, daemon = ephemeral, subagent = SDK agents |
| **Multimodal** | `image` content blocks in SDKUserMessage | **60%** — images yes, audio/video/files uncertain |
| **Provider fallback** | See section 4 below | **50%** — degraded but functional |

### Features with NO SDK Equivalent

| VeilCli Feature | Impact | Mitigation |
|-------------------|--------|-----------|
| **Stall recovery** (inject nudge after N idle iterations) | LOW — Claude Code rarely stalls | Strong system prompt instructions |
| **Anti-drift reminders** (periodic task reminder) | LOW — Claude Code has good task focus | System prompt + `maxTurns` limit |
| **Incremental compaction strategy** (compact N%, custom model) | MEDIUM — loses cost optimization | Claude's default compaction is good |
| **`tool_search`/`tool_activate`** (lazy tool loading) | LOW — MCP servers list all tools upfront | Not needed with in-process MCP |
| **Dynamic tool injection mid-loop** | LOW — rare use case | Set all tools at session creation |

---

## 4. Multi-Provider Fallback — Creative Workaround

### Scenario: claude-cli → openai fallback

If claude-cli fails (CLI not found, auth expired, rate limited):

1. VeilCli has the user message that was going to be sent
2. Fall back to openai provider
3. Create a new openai session with the same agent config
4. Send the user message through the openai path
5. The user loses the Claude CLI session context, but gets a response

### Scenario: openai → claude-cli migration

User wants to move an existing openai session to claude-cli:

1. Read all messages from the VeilCli SQLite session
2. Start a new claude-cli session
3. Inject the conversation summary (or the last N messages) via `systemPrompt.append`:
   ```typescript
   systemPrompt: {
     type: "preset",
     preset: "claude_code",
     append: "Previous conversation context:\n" + summarizedHistory
   }
   ```
4. Or use `shouldQuery: false` to inject messages silently before the first real turn

### Scenario: claude-cli → openai migration

User wants to move a claude-cli session to openai:

1. Use `getSessionMessages(claudeSessionId)` to read Claude's full history
2. Convert SDK message format to OpenAI message format
3. Create a new VeilCli session with these messages in SQLite
4. Resume with openai provider — it sends all messages to the LLM

**It's not seamless, but it IS possible.** The user keeps seeing history in the UI regardless of which engine is active, because VeilCli records all user/assistant messages to SQLite for both engines.

---

## 5. Tool Strategy — What Gets Replaced, What Doesn't

### Tools that USE Claude's built-ins (redundant)

| VeilCli Tool | Claude Built-in | Why use Claude's |
|----------------|----------------|-----------------|
| `read_file` | `Read` | Handles images, PDFs, notebooks natively |
| `write_file` | `Write` | Same capability |
| `edit_file` | `Edit` | Superior line-level editing |
| `glob` | `Glob` | Same capability |
| `grep` | `Grep` | ripgrep-based, more output modes |
| `web_search` | `WebSearch` | Better search infrastructure |
| `web_fetch` | `WebFetch` | Same capability |
| `list_dir` | `Glob`/`Bash ls` | Subsumed |

### Tools that KEEP VeilCli implementation (via in-process MCP)

| Tool | Why keep VeilCli's | Implementation |
|------|---------------------|----------------|
| `bash` | **Custom filters, sandboxing, timeout control** | In-process MCP wrapping VeilCli's bash with restrictions |
| `memory_write/read/search` | VeilCli's persistent memory system (`.veil/memory/`) | In-process MCP with direct DB/filesystem access |
| `todo_write/read` | VeilCli's SQLite-backed todos per session | In-process MCP with direct DB access |
| `log_write` | VeilCli's structured event logging | In-process MCP |
| `sleep` | Simple timer, no Claude equivalent | In-process MCP |
| `agent_spawn/send/message` | VeilCli's inter-agent orchestration | In-process MCP (has access to running sessions, event bus) |
| `task_create/spawn/status/respond/subscribe` | VeilCli's task system | In-process MCP (has access to DB, task runner) |

### Tools that get DROPPED

| Tool | Why drop |
|------|---------|
| `tool_search` | MCP lists all tools upfront — no lazy loading needed |
| `tool_activate` | Same — all MCP tools are already active |

### Bash customization — PreToolUse hook approach

VeilCli can add custom bash restrictions via hooks instead of replacing the tool:

```typescript
hooks: {
  PreToolUse: [{
    matcher: "Bash",
    hooks: [async (input) => {
      const cmd = input.tool_input?.command || '';
      if (cmd.includes('rm -rf /')) {
        return { hookSpecificOutput: { permissionDecision: "deny", permissionDecisionReason: "Dangerous" } };
      }
      // Apply VeilCli's custom filters here
      return { hookSpecificOutput: { permissionDecision: "allow" } };
    }]
  }]
}
```

This lets you keep Claude's superior Bash implementation but add VeilCli's security filters on top.

---

## 6. Scoping & File Structure

### New Files

```
engines/
  claude-cli.js            # Main engine: runClaudeCliSession() async generator
  claude-cli-tools.js      # In-process MCP server wrapping VeilCli tools
  prompt-queue.js           # PromptQueue class (AsyncIterable)
  sdk-event-mapper.js       # SDKMessage → VeilCli event translation
```

### Modified Files (minimal changes)

| File | Change | ~Lines |
|------|--------|--------|
| `llm/provider.js` | `resolveProviderChain` returns type; expose for pre-check before `callWithProviderFallback` | ~15 |
| `core/router.js` | Branch on provider type: `runClaudeCliSession()` vs `runLoop()` | ~25/function |
| `settings/fields.js` | Add `PROVIDER_PATH`, `PROVIDER_PERMISSION_MODE` | ~5 |
| `infrastructure/database.js` | `ensureColumn(db, 'sessions', 'external_session_id', 'TEXT')` | ~1 |
| `schemas/settings.json` | Add `"claude-cli"` to `providerConfig.type` enum | ~1 |

### NOT Modified (zero changes)

| File | Why untouched |
|------|---------------|
| `core/loop.js` | Only used for openai path — claude-cli bypasses it entirely |
| `core/compaction.js` | Only used for openai path |
| `core/default-compaction.js` | Only used for openai path |
| `core/prompt.js` | Output is concatenated into `systemPrompt.append` by the engine |
| `core/registry.js` | Tools are wrapped by `claude-cli-tools.js` via `createSdkMcpServer()` |
| `llm/client.js` | Only used for openai HTTP calls |

### Dependency

```json
"optionalDependencies": {
  "@anthropic-ai/claude-agent-sdk": "^0.2.114"
}
```

Runtime check:
```js
let agentSdk;
try { agentSdk = require('@anthropic-ai/claude-agent-sdk'); } catch { agentSdk = null; }
if (!agentSdk) throw new Error('claude-cli engine requires: npm install @anthropic-ai/claude-agent-sdk');
```

### Removability

Delete `engines/` directory + revert ~50 lines across 4 files + `npm uninstall @anthropic-ai/claude-agent-sdk`. The `external_session_id` column stays (harmless nullable TEXT).

---

## 7. The `runClaudeCliSession()` Engine Design

```
async function* runClaudeCliSession({
  agent, settings, mode, sessionId, cwd, modeConfig,
  providerConfig, userMessage, cancelSignal,
  onStreamChunk, thinking
})
```

### What it does:

```
1. Build system prompt using assembleSystemPrompt() → pass as systemPrompt.append
2. Build in-process MCP server using createSdkMcpServer() with VeilCli tools
3. Map agent tool config → SDK allowedTools/disallowedTools
4. Map agent hooks → SDK hooks format
5. Create PromptQueue
6. Call query({ prompt: promptQueue, options: {
     cwd,
     pathToClaudeCodeExecutable: providerConfig.path || 'claude',
     env: { ...process.env, ...(providerConfig.env || {}) },
     systemPrompt: { type: "preset", preset: "claude_code", append: systemPrompt },
     model: agent.model || undefined,
     thinking: thinking || agent.thinking || undefined,
     permissionMode: providerConfig.permission_mode || 'default',
     canUseTool: bridgedPermissionCallback,
     allowedTools: mappedAllowedTools,
     disallowedTools: mappedDisallowedTools,
     hooks: mappedHooks,
     mcpServers: { "veil_tools": inProcessMcpServer },
     includePartialMessages: true,
     maxTurns: modeConfig.maxIterations || 50,
     maxBudgetUsd: modeConfig.maxBudgetUsd || undefined,
     abortController: cancelController,
     stderr: (data) => console.error('[claude-cli]', data),
   }})
7. Push initial user message into prompt queue
8. Iterate AsyncGenerator:
   - stream_event (text_delta) → call onStreamChunk(), yield to event bus
   - assistant → persist to VeilCli DB, yield message event
   - result → update session totals, yield completion event
   - system/init → store external_session_id, yield init event
   - system/status → yield status event (compacting, active)
   - tool_progress → yield tool event
   - rate_limit_event → yield rate limit event
   - system/hook_* → yield hook events
9. On result: update session in DB (tokens, cost, status)
10. On cancel: runtime.interrupt() + runtime.return(undefined)
```

### Session lifecycle mapping

| VeilCli action | claude-cli implementation |
|-----------------|--------------------------|
| Create session | `db.createSession()` + `query()` + store `external_session_id` |
| Send message | `promptQueue.push(message)` |
| Resume session | `query({ options: { resume: externalSessionId } })` |
| Fork session | `query({ options: { resume: extId, forkSession: true, resumeSessionAt: msgUUID } })` |
| Reset session | `db.resetSession()` + new `query()` (don't resume) |
| Close session | `runtime.return(undefined)` + `promptQueue.close()` + `db.closeSession()` |
| Interrupt | `runtime.interrupt()` |
| Change model | `runtime.setModel(model)` |

---

## 8. Provider Configuration

```json
{
  "providers": {
    "openrouter": {
      "type": "openai",
      "base_url": "https://openrouter.ai/api/v1",
      "api_key": "sk-or-..."
    },
    "claude-local": {
      "type": "claude-cli",
      "path": "claude",
      "permission_mode": "acceptEdits"
    }
  },
  "routing": {
    "default": "openrouter",
    "per_agent": {
      "coder": { "default": "claude-local", "fallback": ["openrouter"] }
    }
  }
}
```

Schema update — add `"claude-cli"` to the enum:
```json
"providerConfig": {
  "properties": {
    "type": { "type": "string", "enum": ["openai", "claude-cli"] },
    "base_url": { "type": "string" },
    "api_key": { "type": "string" },
    "path": { "type": "string" },
    "permission_mode": { "type": "string", "enum": ["default", "acceptEdits", "bypassPermissions", "plan"] },
    "env": { "type": "object", "additionalProperties": { "type": "string" } }
  }
}
```

---

## 9. What Genuinely Cannot Work

After thorough research, only these features have NO equivalent or workaround:

| Feature | Why it truly can't work | Impact |
|---------|------------------------|--------|
| **Custom compaction model/prompt** | Subprocess owns compaction entirely; `PreCompact` hook can observe but not change strategy | MEDIUM — Claude's default compaction is good |
| **Stall recovery injection** | Cannot inject mid-turn between tool calls; interrupt is too disruptive | LOW — Claude Code rarely stalls |
| **Incremental compaction** (compact N% with rolling summary) | Subprocess compaction is all-or-nothing | LOW — mostly a cost optimization |
| **OpenRouter/multi-provider routing** | Claude CLI always uses Anthropic API (or Bedrock/Vertex) | N/A — that's the point of using claude-cli |
| **Dynamic tool activation mid-loop** (`tool_activate`) | `allowedTools` is set at query creation | LOW — set all tools upfront via MCP |

Everything else either works directly, works via a documented SDK API, or has a viable creative workaround.

---

## 10. Conclusion

The claude-cli engine is highly feasible. The SDK is far more capable than initially assessed:

- **`systemPrompt` with `preset + append`** preserves Claude's prompt AND adds VeilCli's
- **`createSdkMcpServer()` with `tool()`** creates in-process MCP — no subprocess needed, full access to VeilCli state
- **Hooks** cover PreToolUse/PostToolUse and 15 more event types
- **Session management** covers resume, fork from message, reset (via new session), listing, and message history
- **Message injection** via prompt queue + `shouldQuery: false` + `streamInput()` covers most of VeilCli's queue system
- **Permission system** via `canUseTool` callback provides programmatic control with full tool input inspection

The architecture is clean: 4 new files in `engines/`, minimal changes to existing files, fully removable. The engine bypasses `core/loop.js` but reuses `core/prompt.js` (via `systemPrompt.append`), `core/registry.js` (via in-process MCP wrapping), and the full database/event infrastructure.
