Build a Terminal Coding Agent

In this tutorial you will build an AI coding assistant that runs entirely in your terminal — no React, no Next.js. Just glove-core and Node.js. The agent reads files, edits code, runs shell commands, and proposes plans — all through a REPL with streaming output and interactive prompts.

The other showcase tutorials use glove-react to render tools as React components. This tutorial shows that the same core engine powers terminal UIs too. The display stack still works — instead of rendering React components, you render terminal prompts. The do function, tool registration, and agent loop are identical.

Prerequisites: You should have read Concepts. Familiarity with Node.js and TypeScript is assumed.

What you will build

A REPL-based coding agent. You type a prompt, the AI streams its response to your terminal, and when it needs to run a tool:

  1. Read and edit files — the tools have direct access to the file system (no API routes, no fetch)
  2. Run shell commands — with a permission prompt that asks you to approve before executing
  3. Propose plans — the agent presents a numbered plan and waits for you to approve, reject, or request changes
  4. Stream output — text from the AI appears character-by-character in real time

Four tools, one subscriber, one display handler. The entire agent fits in two files.

Architecture: core vs. React

In the React tutorials, tools run in the browser and call server API routes via fetch. In the terminal, everything runs in the same Node.js process:

ConcernReact (glove-react)Terminal (glove-core)
Tool executionBrowser — do runs client-sideServer — do runs in Node.js
File accessfetch("/api/fs/read")readFile(path) directly
Display stackReact components via render()Terminal prompts via readline
StreamingReact state updatesprocess.stdout.write()
LLM proxycreateChatHandler on serverModel adapter in same process

The core engine — Glove, Agent, Executor, DisplayManager — is the same. Only the UI layer changes.

1. Project setup

terminalbash
mkdir my-terminal-agent && cd my-terminal-agent
pnpm init
pnpm add glove-core zod
pnpm add -D tsx

glove-core includes the Anthropic SDK, OpenAI SDK, and SQLite driver as dependencies. tsx lets you run TypeScript directly without a build step.

Create a tsconfig.json:

tsconfig.jsonjson
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

2. Define the tools

Tools are defined as objects that match the .fold() signature. Each has a name, description, inputSchema (Zod), and a do function. Since this is Node.js, the do function has direct access to fs, child_process, and everything else — no API routes needed.

tools.tstypescript
import z from "zod";
import { readFile, writeFile } from "fs/promises";
import { exec } from "child_process";
import type { DisplayManagerAdapter } from "glove-core";

// ─── read_file ───────────────────────────────────────────────────────────────

export const readFileDef = {
  name: "read_file",
  description:
    "Read the contents of a file. Returns the text with line numbers.",
  inputSchema: z.object({
    path: z.string().describe("Path to the file to read"),
  }),
  async do(input: { path: string }) {
    const content = await readFile(input.path, "utf-8");
    const lines = content.split("\n");
    const numbered = lines
      .map((line, i) => `${i + 1} | ${line}`)
      .join("\n");
    return `${input.path} (${lines.length} lines)\n${numbered}`;
  },
};

// ─── edit_file ───────────────────────────────────────────────────────────────

export const editFileDef = {
  name: "edit_file",
  description:
    "Edit a file by replacing a specific string. The old_string must " +
    "appear exactly once. Use read_file first to see the exact content.",
  inputSchema: z.object({
    path: z.string().describe("Path to the file to edit"),
    old_string: z.string().describe("Exact string to find and replace"),
    new_string: z.string().describe("Replacement text"),
  }),
  async do(input: { path: string; old_string: string; new_string: string }) {
    const content = await readFile(input.path, "utf-8");

    const count = content.split(input.old_string).length - 1;
    if (count === 0) throw new Error("old_string not found in file.");
    if (count > 1) throw new Error(`old_string found ${count} times. Must be unique.`);

    const updated = content.replace(input.old_string, input.new_string);
    await writeFile(input.path, updated, "utf-8");
    return `Edited ${input.path}`;
  },
};

// ─── bash ────────────────────────────────────────────────────────────────────

export const bashDef = {
  name: "bash",
  description:
    "Execute a shell command. Returns stdout + stderr. " +
    "Requires user permission before running.",
  inputSchema: z.object({
    command: z.string().describe("The shell command to execute"),
    timeout: z
      .number()
      .optional()
      .describe("Timeout in seconds. Defaults to 30"),
  }),
  requiresPermission: true,
  async do(input: { command: string; timeout?: number }) {
    const timeout = (input.timeout ?? 30) * 1000;
    return new Promise<string>((resolve) => {
      exec(
        input.command,
        { timeout, maxBuffer: 1024 * 1024 * 5, shell: "/bin/bash" },
        (error, stdout, stderr) => {
          const parts: string[] = [];
          if (stdout.trim()) parts.push(stdout.trim());
          if (stderr.trim()) parts.push(stderr.trim());
          if (error?.killed) parts.push(`Timed out after ${input.timeout ?? 30}s`);
          resolve(parts.join("\n") || "(no output)");
        },
      );
    });
  },
};

// ─── plan ────────────────────────────────────────────────────────────────────

export const planDef = {
  name: "plan",
  description:
    "Present a step-by-step plan for user approval before making " +
    "changes. ALWAYS use this before editing files. Blocks until " +
    "the user approves, rejects, or requests modifications.",
  inputSchema: z.object({
    title: z.string().describe("Short title summarizing the plan"),
    steps: z
      .array(z.string())
      .describe("Ordered list of concrete steps"),
  }),
  async do(
    input: { title: string; steps: string[] },
    display: DisplayManagerAdapter,
  ) {
    // Push a slot and block until the terminal handler resolves it
    const result = await display.pushAndWait({
      renderer: "plan_approval",
      input: { title: input.title, steps: input.steps },
    });
    return JSON.stringify(result);
  },
};

Notice the differences from the React tutorials:

3. Stream output to the terminal

A subscriber listens to events from the agent and prints them. The key event is text_delta — it fires for each chunk of text as the AI streams its response.

subscriber.tstypescript
import type { SubscriberAdapter } from "glove-core";

export class TerminalSubscriber implements SubscriberAdapter {
  async record(event_type: string, data: any) {
    switch (event_type) {
      case "text_delta":
        // Stream text character-by-character
        process.stdout.write(data.text);
        break;

      case "tool_use":
        console.log(`\n🔧 ${data.name}`);
        break;

      case "tool_use_result":
        if (data.result.status === "error") {
          console.log(`${data.tool_name}: ${data.result.message}`);
        } else {
          console.log(`${data.tool_name}`);
        }
        break;

      case "model_response_complete":
        // Streaming finished — add a newline
        console.log();
        break;
    }
  }
}

Four events are all you need. text_delta uses process.stdout.write (not console.log) to avoid adding newlines between chunks. The result is smooth, character-by-character streaming in the terminal.

4. Handle interactive prompts

When a tool calls display.pushAndWait(), a slot is pushed onto the display stack. In React, the slot renders as a component. In the terminal, you subscribe to the display manager and handle each slot with readline.

prompt-handler.tstypescript
import * as readline from "node:readline/promises";
import type { DisplayManagerAdapter, Slot } from "glove-core";

export function setupPromptHandler(
  dm: DisplayManagerAdapter,
  rl: readline.Interface,
) {
  const handled = new Set<string>();

  dm.subscribe(async (stack: Slot<unknown>[]) => {
    for (const slot of stack) {
      if (handled.has(slot.id)) continue;
      handled.add(slot.id);

      // Fire-and-forget — the resolver is set up after subscribe returns
      handleSlot(dm, rl, slot);
    }
  });
}

async function handleSlot(
  dm: DisplayManagerAdapter,
  rl: readline.Interface,
  slot: Slot<unknown>,
) {
  // Yield to let the resolver be registered
  await new Promise((r) => setTimeout(r, 0));

  const input = slot.input as any;

  switch (slot.renderer) {
    case "permission_request": {
      const answer = await rl.question(
        `\n⚡ Allow "${input.toolName}" to run? [y/n]: `,
      );
      dm.resolve(slot.id, answer.toLowerCase().startsWith("y"));
      break;
    }

    case "plan_approval": {
      console.log(`\n📋 ${input.title}`);
      input.steps.forEach((step: string, i: number) => {
        console.log(`   ${i + 1}. ${step}`);
      });
      const answer = await rl.question("[a]pprove / [r]eject / [m]odify: ");
      const action = answer.toLowerCase().startsWith("a")
        ? "approve"
        : answer.toLowerCase().startsWith("m")
          ? "modify"
          : "reject";

      let feedback: string | undefined;
      if (action === "modify") {
        feedback = await rl.question("What should change? ");
      }

      dm.resolve(slot.id, { action, feedback });
      break;
    }

    default:
      console.log(`[unknown slot: ${slot.renderer}]`);
      dm.resolve(slot.id, null);
  }
}

The important detail: handleSlot is called without await (fire-and-forget). This lets the subscribe callback return immediately, which allows the display manager to finish setting up the resolver before handleSlot tries to resolve the slot.

The handled set prevents double-prompting — the subscribe callback fires every time the stack changes, so the same slot could appear multiple times.

The permission prompt (permission_request) is triggered automatically by the executor when a tool has requiresPermission: true. You do not call it from the tool — the executor pushes the slot for you.

5. Wire it all together

The main file creates the store, model, display manager, and Glove instance, then runs a REPL loop.

agent.tstypescript
import * as readline from "node:readline/promises";
import { Glove, SqliteStore, Displaymanager, AnthropicAdapter } from "glove-core";
import { readFileDef, editFileDef, bashDef, planDef } from "./tools";
import { TerminalSubscriber } from "./subscriber";
import { setupPromptHandler } from "./prompt-handler";

// ─── 1. Store ────────────────────────────────────────────────────────────────
// Use ":memory:" for ephemeral sessions, or a file path for persistence.

const store = new SqliteStore({
  dbPath: "./agent.db",
  sessionId: "main",
});

// ─── 2. Model ────────────────────────────────────────────────────────────────

const model = new AnthropicAdapter({
  model: "claude-sonnet-4-20250514",
  stream: true,
  // Uses ANTHROPIC_API_KEY env var by default
});

// ─── 3. Display manager ─────────────────────────────────────────────────────

const dm = new Displaymanager();

// ─── 4. Build the agent ─────────────────────────────────────────────────────

const glove = new Glove({
  store,
  model,
  displayManager: dm,
  systemPrompt: `You are a careful, thorough coding assistant running in a terminal.

Your workflow:
1. When given a task, start by reading relevant files to understand the code.
2. Use the plan tool before making any changes. Present clear steps and wait
   for approval.
3. After approval, make changes one at a time using edit_file.
4. After edits, use bash to run tests or verify the changes.
5. If the user rejects a plan, ask what they want to change.

Rules:
- Never edit a file without showing a plan first.
- Never run a command without explaining why.
- Keep explanations concise.`,
  compaction_config: {
    compaction_instructions:
      "Summarize the conversation. Preserve: files modified, " +
      "task state, errors encountered, key decisions made.",
  },
});

// Register tools
glove
  .fold(readFileDef)
  .fold(editFileDef)
  .fold(bashDef)
  .fold(planDef);

// Add streaming subscriber
glove.addSubscriber(new TerminalSubscriber());

// Build
const agent = glove.build();

// ─── 5. Set up terminal prompt handler ──────────────────────────────────────

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

setupPromptHandler(dm, rl);

// ─── 6. REPL ────────────────────────────────────────────────────────────────

console.log("Terminal Coding Agent");
console.log("Type a message to start. Ctrl+C to exit.\n");

async function repl() {
  while (true) {
    const input = await rl.question("you > ");
    if (!input.trim()) continue;

    try {
      await agent.processRequest(input.trim());
    } catch (err: any) {
      console.error(`\nError: ${err.message}`);
    }

    console.log(); // blank line between turns
  }
}

repl().catch(console.error);

That is the entire agent. The pieces:

6. Run it

terminalbash
ANTHROPIC_API_KEY=sk-... npx tsx agent.ts

Try these prompts:

How the agent loop works

When you call processRequest("Add a start script"), the engine runs this loop:

  1. Your message is appended to the conversation history in the store
  2. The model adapter sends the full history + tool schemas to the LLM. The subscriber receives text_delta events as the response streams.
  3. If the LLM response includes tool calls, the executor runs each one:
    • For tools with requiresPermission, the executor pushes a permission_request slot onto the display stack. Your terminal handler prompts the user.
    • The tool's do function executes. If it calls display.pushAndWait(), another slot is pushed and the terminal handler prompts again.
    • Tool results are appended to the history and sent back to the LLM.
  4. The loop continues until the LLM responds with text only (no tool calls). processRequest returns.

The compaction config kicks in when token usage exceeds the limit. The engine summarizes the conversation and starts fresh, preserving task state. This lets long sessions continue without hitting context limits.

Display patterns used

ToolDisplayTerminal behavior
read_fileNoneSilent — returns data to the AI
edit_fileNoneSilent — writes file directly
bashAuto permission“Allow bash to run? [y/n]”
planpushAndWaitShows numbered plan, prompts approve/reject/modify

Swapping the model

The AnthropicAdapter can be replaced with any provider. For OpenAI or OpenAI-compatible APIs:

agent.ts (alternative model)typescript
import { OpenAICompatAdapter } from "glove-core";

const model = new OpenAICompatAdapter({
  model: "gpt-4o-mini",
  baseURL: "https://api.openai.com/v1",
  stream: true,
  // Uses OPENAI_API_KEY env var by default
});

For OpenRouter, Gemini, or other providers, change the baseURL and apiKey. You can also hot-swap the model at runtime:

agent.ts (hot-swap)typescript
// Switch model between requests
agent.setModel(new AnthropicAdapter({
  model: "claude-opus-4-20250514",
  stream: true,
}));

Adding more tools

The examples/coding-agent directory includes a full set of tools you can add to your agent:

Each tool follows the same Tool<I> interface. To register them, either use .fold() or register directly on the executor.

Next steps