Give Agents and Humans the Same Interface
When a capability exists as a CLI command but not as a web page (or
vice versa), someone eventually rewrites the logic for the second
surface. The two implementations drift apart, and agents that
learned one interface cannot reach the other.
@forwardimpact/libcli,
@forwardimpact/libui, and
@forwardimpact/libformat let you write a capability
once and surface it on both the terminal and the browser through a
shared contract.
Prerequisites
- Node.js 18+
- Install all three libraries:
npm install @forwardimpact/libcli @forwardimpact/libui @forwardimpact/libformat
How the shared contract works
Both surfaces produce the same frozen object -- an
InvocationContext -- from their native inputs. The
CLI builds it from argv; the web router builds it from
the URL hash. Handlers receive this object and never know which
surface called them.
{
data, // Object -- your application data (passed in by the host)
args, // { city: "london" } -- named positional arguments
options, // { units: "metric" } -- flags or query parameters
}
Value types are uniform across surfaces.
args values are always strings.
options values are string,
boolean (true for presence-only flags or
empty query params), or string[] (repeated keys). No
nulls, no numbers -- if you need a number, parse it in the handler.
The context is frozen at every level. Handlers can rely on immutability without checking.
1. Write the shared presenter
The presenter takes an InvocationContext, looks up data, and returns a plain view object. No DOM, no stdout -- data in, data out.
// src/present-forecast.js
export function presentForecast(ctx) {
const city = ctx.data.cities.find((c) => c.id === ctx.args.city);
if (!city) throw new Error(`Unknown city: ${ctx.args.city}`);
const forecast = city.forecast;
return {
city: city.name,
temp: forecast.temp,
units: ctx.options.units || "metric",
condition: forecast.condition,
wind: forecast.wind,
};
}
Because the presenter depends only on a plain frozen object, you can test it without a browser or a running process:
// test/present-forecast.test.js
import { freezeInvocationContext } from "@forwardimpact/libcli";
import { presentForecast } from "../src/present-forecast.js";
import assert from "node:assert";
const ctx = freezeInvocationContext({
data: {
cities: [{
id: "london",
name: "London",
forecast: { temp: 14, condition: "Cloudy", wind: "12 km/h" },
}],
},
args: { city: "london" },
options: { units: "metric" },
});
const view = presentForecast(ctx);
assert.strictEqual(view.city, "London");
assert.strictEqual(view.temp, 14);
assert.strictEqual(view.units, "metric");
Both surfaces call the same function, so a passing presenter test covers the core logic for the CLI and the web UI at once.
2. Format the output for each surface
@forwardimpact/libformat provides two formatters that
render the same markdown content to different targets. The terminal
formatter produces ANSI-styled text; the HTML formatter produces
sanitized HTML. Both implement the same
{ format(markdown) } interface, so you can write one
formatting function and swap the formatter at the surface boundary.
// src/format-forecast.js
export function formatForecast(view, formatter) {
const md = [
`## ${view.city}`,
"",
`| Metric | Value |`,
`| --------- | -------------- |`,
`| Temp | ${view.temp} (${view.units}) |`,
`| Condition | ${view.condition} |`,
`| Wind | ${view.wind} |`,
].join("\n");
return formatter.format(md);
}
The CLI surface uses createTerminalFormatter; the web
surface uses createHtmlFormatter. The presenter stays
the same either way.
3. Build the CLI surface
The CLI definition declares named positionals with
args: string[] and a handler that calls
the shared presenter and formatter:
#!/usr/bin/env node
// bin/weather.js
import { createCli } from "@forwardimpact/libcli";
import { createTerminalFormatter } from "@forwardimpact/libformat";
import { presentForecast } from "../src/present-forecast.js";
import { formatForecast } from "../src/format-forecast.js";
import { loadCities } from "../src/data.js";
const formatter = createTerminalFormatter();
const cli = createCli({
name: "weather",
version: "0.1.0",
description: "Weather forecasts from the terminal",
commands: [
{
name: "forecast",
args: ["city"],
argsUsage: "<city>",
description: "Show forecast for a city",
handler: (ctx) => {
const view = presentForecast(ctx);
if (ctx.options.json) {
console.log(JSON.stringify(view, null, 2));
} else {
console.log(formatForecast(view, formatter));
}
},
},
],
globalOptions: {
units: { type: "string", description: "Temperature units (metric|imperial)" },
json: { type: "boolean", description: "JSON output" },
help: { type: "boolean", short: "h", description: "Show help" },
version: { type: "boolean", description: "Show version" },
},
});
const parsed = cli.parse(process.argv.slice(2));
if (!parsed) process.exit(0);
const data = { cities: loadCities() };
cli.dispatch(parsed, { data });
cli.dispatch builds the InvocationContext internally --
it maps the positional argv values to the names declared in
args: ["city"], merges the parsed flags into
options, folds in the data you provide,
freezes everything, and calls the handler.
Running weather forecast london produces ANSI-formatted
output. Running
weather forecast london --json produces:
{
"city": "London",
"temp": 14,
"units": "metric",
"condition": "Cloudy",
"wind": "12 km/h"
}
Agents can always pass --json to get structured output.
The --help flag renders a grep-friendly synopsis, and
--help --json returns the full definition as JSON so
agents can discover the interface programmatically.
4. Build the web surface
The web side uses defineRoute to declare a route and
createBoundRouter to dispatch it. The route
descriptor's page function calls the same
presenter:
// src/main.js
import {
createBoundRouter,
defineRoute,
createCommandBar,
} from "@forwardimpact/libui";
import { createHtmlFormatter } from "@forwardimpact/libformat";
import { presentForecast } from "./present-forecast.js";
const data = await fetch("/api/cities.json").then((r) => r.json());
const formatter = createHtmlFormatter();
const router = createBoundRouter({
data,
onNotFound: () => document.body.textContent = "City not found",
});
router.register(defineRoute({
pattern: "/forecast/:city",
page: (ctx) => {
const view = presentForecast(ctx);
document.getElementById("app").innerHTML = formatter.format(
`## ${view.city}\n\n${view.temp} ${view.units}, ${view.condition}`
);
},
cli: (ctx) => `weather forecast ${ctx.args.city}`,
}));
createCommandBar(router, {
mountInto: document.getElementById("command-bar"),
});
router.start();
When the user navigates to #/forecast/london, the bound
router:
-
Matches the pattern and extracts
{ city: "london" }asargs - Parses the query string (if any) into
options - Freezes everything into an InvocationContext
- Calls
page(ctx)
The command bar displays weather forecast london with a
copy button. An agent or a person reading the web page can paste
that command into a terminal to get the same result through the CLI.
The cli function on the descriptor is optional. When
present, the command bar displays the equivalent CLI command. Routes
without cli render the bar empty.
5. Verify both surfaces reach the same result
The simplest way to confirm both surfaces produce equivalent output is to check that both call the same presenter with the same context shape:
import { freezeInvocationContext } from "@forwardimpact/libcli";
import { presentForecast } from "../src/present-forecast.js";
import assert from "node:assert";
// Simulate what the CLI surface builds from: weather forecast london --units=imperial
const cliCtx = freezeInvocationContext({
data: { cities: [{ id: "london", name: "London", forecast: { temp: 57, condition: "Rain", wind: "8 mph" } }] },
args: { city: "london" },
options: { units: "imperial" },
});
// Simulate what the web surface builds from: #/forecast/london?units=imperial
const webCtx = freezeInvocationContext({
data: { cities: [{ id: "london", name: "London", forecast: { temp: 57, condition: "Rain", wind: "8 mph" } }] },
args: { city: "london" },
options: { units: "imperial" },
});
const cliView = presentForecast(cliCtx);
const webView = presentForecast(webCtx);
assert.deepStrictEqual(cliView, webView);
Both contexts are structurally identical because both surfaces follow the same contract. The presenter does not branch on which surface produced the context -- it cannot tell.
Query string parsing
The web surface parses the query string after ? in the
URL hash using URLSearchParams:
| Input | Result |
|---|---|
?json |
{ json: true } |
?units=imperial |
{ units: "imperial" } |
?tag=rain&tag=wind |
{ tag: ["rain", "wind"] }
|
| (no query) | {} |
Empty values become true; repeated keys become arrays;
everything else is a string. This matches the CLI's flag
parsing: --json produces { json: true },
--units=imperial produces
{ units: "imperial" }, and
--tag=rain --tag=wind produces
{ tag: ["rain", "wind"] }.
What's next
Once both surfaces share a presenter, adding a new capability is a bounded task: write the presenter, register a CLI command, register a route, and both surfaces gain the feature at once. See Add a Capability for the step-by-step procedure.