Give Agents and Humans the Same Interface

A capability can exist as a CLI command with no web page. The reverse also happens. Someone eventually rewrites the logic for the second surface. The two implementations drift apart. Agents that learned one interface cannot reach the other.

@forwardimpact/libcli, @forwardimpact/libui, and @forwardimpact/libformat let you write a capability once. A shared contract puts that capability on both the terminal and the browser.

Prerequisites

  • Node.js 22+
  • 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. They 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). There are no nulls and no numbers. If you need a number, parse it in the handler.

The context is frozen at every level. Handlers can rely on immutability with no check.

1. Write the shared presenter

The presenter takes an InvocationContext, looks up data, and returns a plain view object. It uses no DOM and no stdout. It receives data and returns data.

// 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 and without a live 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 presenter test that passes 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 function that formats. You then 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"]. It merges the parsed flags into options. It folds in the data you provide. It freezes everything. It then calls the handler.

The command weather forecast london produces ANSI-formatted output. The command 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. The --help --json flags return the full definition as JSON. Agents can then 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 who reads the web page can paste that command into a terminal. The command gives the same result through the CLI.

The cli function on the descriptor is optional. When it is present, the command bar displays the equivalent CLI command. Routes without cli render the bar empty.

5. Verify both surfaces reach the same result

To confirm both surfaces produce equivalent output, 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 cannot detect which surface produced the context, so it does not branch.

How the web surface parses the query string

The web surface parses the query string after ? in the URL hash with 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 how the CLI parses flags. --json produces { json: true }. --units=imperial produces { units: "imperial" }. --tag=rain --tag=wind produces { tag: ["rain", "wind"] }.

What's next