Drive Claude Code programmatically — as a simple API or through an interactive TUI.
Programmatic API
import { Claude } from "./src/claude"; const claude = new Claude({ cwd: "./my-project", onQuestion: async (questions) => { // Called when Claude asks questions via AskUserQuestion. // Return an answer string for each question. return questions.map(q => q.options[0].label); }, }); // Send a message, wait for the full response const res = await claude.send("Build a hello world web app"); console.log(res.text); // assistant text console.log(res.toolCalls); // [{tool: "Write", input: {...}}] console.log(res.messages); // all raw messages from this turn // Or stream events as they arrive for await (const event of claude.stream("Add tests")) { if (event.type === "text") process.stdout.write(event.text); if (event.type === "tool_use") console.log(`> ${event.tool}`); if (event.type === "done") console.log("Done!"); } claude.destroy();
API reference
new Claude(options?)
| Option | Type | Default | Description |
|---|---|---|---|
cwd |
string |
process.cwd() |
Working directory for Claude |
onQuestion |
(questions: Question[]) => string[] |
Auto-selects first option | Callback for AskUserQuestion prompts |
claude.send(text): Promise<Response>
Send a message and wait for the complete response.
interface Response { text: string; // all assistant text joined toolCalls: ToolCall[]; // [{tool, input}] messages: ChatMessage[];// raw messages from this turn }
claude.stream(text): AsyncGenerator<StreamEvent>
Send a message and yield events as they arrive.
type StreamEvent = | { type: "text"; text: string } | { type: "thinking"; text: string } | { type: "tool_use"; tool: string; input: string } | { type: "tool_result"; content: string } | { type: "question"; questions: Question[]; answers: string[] } | { type: "done"; response: Response };
claude.destroy()
Kill the Claude process and clean up.
Interactive TUI
An ink-based chat interface for interactive use:
- Type messages and press Enter to send
- When Claude asks questions, type a number to select an option or text for custom answer
- Press Escape or type /q to quit
Architecture
┌──────────────┐ ┌───────────────────┐ ┌──────────────────┐
│ Claude API │────▶│ ClaudeDriver │────▶│ Claude Code │
│ (src/claude)│ │ (src/claude- │ │ (hidden PTY) │
│ Ink TUI │◀────│ driver) │◀────│ │
│ (src/tui) │ └───────────────────┘ └──────────────────┘
└──────────────┘ │ ▲
│ │ idle signals
▼ │ (unix socket)
┌───────────────────┐
│ Session JSONL │
│ (~/.claude/ │
│ projects/...) │
└───────────────────┘
- Hidden PTY — Claude Code runs in a pseudo-terminal but its TUI output is swallowed. Input is sent via
pty.write(). - Session file tailing — Claude Code writes conversation data to
~/.claude/projects/<project>/<session>.jsonl. The driver polls this file for new entries and emits structured events. - Idle signal hooks — Claude Code hooks (
Stop,Notification,PreToolUse) forward signals to the driver via a Unix domain socket for state detection.
Key files
| File | Description |
|---|---|
src/claude.ts |
High-level API: send(), stream(), question callbacks |
src/claude-driver.ts |
Core driver: PTY management, session file tailing, idle signals |
src/tui.tsx |
Ink-based chat TUI |
src/pty-driver.ts |
Original standalone PTY driver |
.claude/hooks/on-idle.ts |
Hook script forwarding Claude signals via Unix socket |
Setup
Known limitations
- AskUserQuestion answers are sent by dismissing Claude's dialog (Escape) then sending the answer as a follow-up message, rather than interacting with Claude's native dialog directly
- Permission prompts (file edits, bash commands) are handled by Claude's hidden PTY — the TUI doesn't surface them yet
- No scrollback or message history beyond the visible terminal height