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.
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:
fetch)Four tools, one subscriber, one display handler. The entire agent fits in two files.
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:
| Concern | React (glove-react) | Terminal (glove-core) |
|---|---|---|
| Tool execution | Browser — do runs client-side | Server — do runs in Node.js |
| File access | fetch("/api/fs/read") | readFile(path) directly |
| Display stack | React components via render() | Terminal prompts via readline |
| Streaming | React state updates | process.stdout.write() |
| LLM proxy | createChatHandler on server | Model adapter in same process |
The core engine — Glove, Agent, Executor, DisplayManager — is the same. Only the UI layer changes.
mkdir my-terminal-agent && cd my-terminal-agent
pnpm init
pnpm add glove-core zod
pnpm add -D tsxglove-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:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}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.
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:
read_file and edit_file call readFile and writeFile directly — no fetch to a server routebash has requiresPermission: true. The executor automatically pushes a permission prompt onto the display stack before running the tool. You do not handle permissions inside do.plan calls display.pushAndWait() directly. The do function receives the display manager as its second argument — this is the same object that powers React slots, but here it drives terminal prompts.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.
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.
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.
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.
The main file creates the store, model, display manager, and Glove instance, then runs a REPL loop.
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:
SqliteStore — persists messages, tokens, tasks, and permissions in a local SQLite file. The agent remembers previous conversations across restarts.AnthropicAdapter — connects to the Anthropic API with streaming. You can swap this for OpenAICompatAdapter to use OpenAI, Gemini, or any OpenAI-compatible provider.Displaymanager — the same display stack that powers React slots. Here it drives terminal prompts..fold() — registers each tool. The builder validates the schema and wires the do function into the executor..addSubscriber() — hooks up the terminal subscriber for streaming output..build() — locks the configuration and returns a runnable agent.processRequest() — sends a message to the agent. The agent calls the LLM, executes tools, loops until the response is complete, and returns.ANTHROPIC_API_KEY=sk-... npx tsx agent.tsTry these prompts:
read_file and prints the content with line numbersWhen you call processRequest("Add a start script"), the engine runs this loop:
text_delta events as the response streams.requiresPermission, the executor pushes a permission_request slot onto the display stack. Your terminal handler prompts the user.do function executes. If it calls display.pushAndWait(), another slot is pushed and the terminal handler prompts again.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.
| Tool | Display | Terminal behavior |
|---|---|---|
read_file | None | Silent — returns data to the AI |
edit_file | None | Silent — writes file directly |
bash | Auto permission | “Allow bash to run? [y/n]” |
plan | pushAndWait | Shows numbered plan, prompts approve/reject/modify |
The AnthropicAdapter can be replaced with any provider. For OpenAI or OpenAI-compatible APIs:
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:
// Switch model between requests
agent.setModel(new AnthropicAdapter({
model: "claude-opus-4-20250514",
stream: true,
}));The examples/coding-agent directory includes a full set of tools you can add to your agent:
list_dir — tree-style directory listingsearch / grep — codebase search with ripgrep fallbackwrite_file — create new filesglob — find files by patterngit_status / git_diff / git_log — git operationsask_question — ask the user a question with optional choicesEach tool follows the same Tool<I> interface. To register them, either use .fold() or register directly on the executor.
pushAndForget for persistent cardsGlove, SqliteStore, AnthropicAdapter, and SubscriberAdapter