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:

  1. Matches the pattern and extracts { city: "london" } as args
  2. Parses the query string (if any) into options
  3. Freezes everything into an InvocationContext
  4. 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.