GitHub - jensb1/jensagent

GitHub

3 min read Original article ↗

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/...)   │
                     └───────────────────┘
  1. Hidden PTY — Claude Code runs in a pseudo-terminal but its TUI output is swallowed. Input is sent via pty.write().
  2. 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.
  3. 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