Build an Interactive REPL
A CLI answers one question per invocation. Some work is exploratory:
a person or an agent issues a command, reads the result, and issues
the next command with the previous one in mind.
@forwardimpact/librepl provides a
Repl that runs that loop. The same command set works
two ways — typed at an interactive prompt and passed as one-shot
flags — so an agent that learned the flags can drive the tool
non-interactively, and a person can explore the same commands by
hand.
Prerequisites
- Node.js 22+
- Install librepl:
npm install @forwardimpact/librepl
The terminal formatter ships as a dependency, so REPL output is rendered the same way as the rest of the shared-surface stack.
1. Define the application
A Repl is constructed from an application object. The
two fields you will almost always set are commands (the
named operations) and onLine (what to do with a plain
line of input that is not a command).
#!/usr/bin/env node
// bin/notes.js
import { Repl } from "@forwardimpact/librepl";
import { Readable } from "node:stream";
const repl = new Repl({
prompt: "notes> ",
state: { entries: [] },
onLine: async (line, state, output) => {
state.entries.push(line);
output.end(`Saved. ${state.entries.length} note(s) total.`);
},
commands: {
list: {
usage: "Show all saved notes",
type: "boolean",
handler: async (_args, state) => {
const body = state.entries.length
? state.entries.map((e, i) => `${i + 1}. ${e}`).join("\n")
: "No notes yet.";
return Readable.from([body]);
},
},
},
});
await repl.start();
Three things are happening here:
-
statedeclares the application's data and its initial values. The same object is passed to every handler, so commands read and write shared state. -
onLinereceives a plain input line, the livestate, and anoutputstream. Write the result tooutputand calloutput.end()when done. -
Each entry in
commandshas ausagestring and ahandler. A command markedtype: "boolean"takes no arguments. A handler may return aReadablestream to print output, or returnfalseto exit early.
2. Run it both ways
The same definition drives two modes, chosen automatically by whether input is a terminal.
Interactive — run the binary with a terminal attached:
notes
notes> Buy milk
Saved. 1 note(s) total.
notes> /list
1. Buy milk
notes>
Commands are typed with a leading /; anything else is a
line for onLine.
Non-interactive — every command is also a
--flag, so an agent can invoke the same operations
without a prompt:
notes --list
The --list flag maps to the list command.
A command whose name has underscores maps to a dashed flag (clear_cache
becomes --clear-cache). Piping input on stdin runs each
line through the same handler the interactive prompt uses, so a
recorded session replays exactly.
3. Persist state between sessions
By default, state lives only for the duration of the process. Pass a
storage object and the REPL loads state on start and
saves it after every line. The storage object implements a small
interface — exists(key), get(key), and
put(key, value) — so you choose where state lives.
import { Repl } from "@forwardimpact/librepl";
const memory = new Map();
const storage = {
async exists(key) {
return memory.has(key);
},
async get(key) {
return memory.get(key);
},
async put(key, value) {
memory.set(key, value);
},
};
const repl = new Repl({
prompt: "notes> ",
state: { entries: [] },
storage,
onLine: async (line, state, output) => {
state.entries.push(line);
output.end(`Saved. ${state.entries.length} note(s) total.`);
},
});
await repl.start();
State is keyed per user, so two people on the same machine keep
separate histories. @forwardimpact/libstorage provides
ready-made backends (local files, S3, Supabase) that satisfy this
interface — see
Ground Agents in Context
— but any object with the three methods works, which keeps tests
free of real I/O.
Built-in commands
Three commands exist on every REPL without being declared:
| Command | Flag | Effect |
|---|---|---|
/help |
--help |
Print usage, the command list, and doc links |
/clear |
--clear |
Reset state to its declared initial values |
/exit |
(none) | Leave the interactive prompt |
/exit is interactive-only; it does not appear in the
flag list because exiting has no meaning in one-shot mode. Your own
commands are merged with these and the combined list is sorted
alphabetically in the help output.
Discovery links for agents
The help output can carry a documentation array — the
same external links an agent finds in a matching skill. An agent
reaching the REPL through --help gets the same
progressive-disclosure links it would get anywhere else:
const repl = new Repl({
prompt: "notes> ",
documentation: [
{
title: "Build an Interactive REPL",
url: "https://www.forwardimpact.team/docs/libraries/every-surface/interactive-repl/index.md",
description: "Command definitions, state persistence, and storage",
},
],
// ...commands, onLine, state
});
Verify
-
notes --helplists every command in both the flag form and the/form. -
A plain line at the prompt
reaches
onLineand updatesstate. -
/list(andnotes --list) return the same output for the same state. -
With a
storageobject set, a value saved in one session is present after restarting the process. -
/clearresetsstateto the initial values declared on the app.
What's next
Give Agents and Humans the Same Interface
Capabilities that work on every surface — one presenter, one contract, and one formatter shared between CLI and web, with no separate integrations.
Render Templates with Project Overrides
Ship default templates with a package and let each project override any one of them — two-tier Mustache resolution that keeps generated output consistent across surfaces.