Add a Capability to Both Surfaces
You need to add a new capability to an application that already shares a presenter between its CLI and web surfaces. This page shows how one implementation makes the capability appear in both places.
Prerequisites
Complete the
Agent-Friendly Surfaces
guide first. This page assumes a working
createCli definition, a createBoundRouter,
and the shared presenter pattern.
- Node.js 22+
- The three surface libraries installed:
npm install @forwardimpact/libcli @forwardimpact/libui @forwardimpact/libformat
Overview
You add a capability in four steps. Each step produces a testable artifact:
| Step | Artifact | What it does |
|---|---|---|
| 1 | Presenter | Transforms context into a plain view object |
| 2 | Format function | Renders the view through a surface-agnostic formatter |
| 3 | CLI command | Registers the presenter as a subcommand |
| 4 | Web route | Registers the presenter as a hash route |
Step 1: Write the presenter
The presenter receives an InvocationContext and returns
a plain object. It must not reference the DOM, stdout, or any
surface-specific API.
// src/present-status.js
export function presentStatus(ctx) {
const service = ctx.data.services.find((s) => s.id === ctx.args.service);
if (!service) throw new Error(`Unknown service: ${ctx.args.service}`);
return {
name: service.name,
healthy: service.healthy,
uptime: service.uptime,
version: service.version,
};
}
Test the presenter with freezeInvocationContext. Both
surfaces use the same helper internally:
// test/present-status.test.js
import { freezeInvocationContext } from "@forwardimpact/libcli";
import { presentStatus } from "../src/present-status.js";
import assert from "node:assert";
const ctx = freezeInvocationContext({
data: {
services: [{
id: "api", name: "API Gateway",
healthy: true, uptime: "14d 3h", version: "2.1.0",
}],
},
args: { service: "api" },
options: {},
});
const view = presentStatus(ctx);
assert.strictEqual(view.name, "API Gateway");
assert.strictEqual(view.healthy, true);
The context is frozen and surface-neutral. So a test that passes here covers the core logic for both CLI and web.
Step 2: Write the format function
Write markdown and call formatter.format(md). Both
createTerminalFormatter and
createHtmlFormatter share that interface.
// src/format-status.js
export function formatStatus(view, formatter) {
const status = view.healthy ? "healthy" : "degraded";
const md = [
`## ${view.name}`,
"",
`| Field | Value |`,
`| ------- | ------------ |`,
`| Status | ${status} |`,
`| Uptime | ${view.uptime} |`,
`| Version | ${view.version} |`,
].join("\n");
return formatter.format(md);
}
You inject the formatter at the surface boundary. This function does not decide whether output becomes ANSI or HTML.
Step 3: Register the CLI command
Add a command entry to your existing
createCli definition. Declare the positional in
args and call the presenter inside the handler:
// In your CLI definition file (e.g. bin/myapp.js)
import { presentStatus } from "../src/present-status.js";
import { formatStatus } from "../src/format-status.js";
// Add this entry to the commands array:
{
name: "status",
args: ["service"],
argsUsage: "<service>",
description: "Show service health status",
handler: (ctx) => {
const view = presentStatus(ctx);
if (ctx.options.json) {
console.log(JSON.stringify(view, null, 2));
} else {
console.log(formatStatus(view, formatter));
}
},
}
myapp status api produces formatted terminal output.
myapp status api --json produces:
{
"name": "API Gateway",
"healthy": true,
"uptime": "14d 3h",
"version": "2.1.0"
}
Agents pass --json to get structured output. They do
not parse formatted text.
Step 4: Register the web route
Register a defineRoute with the existing
createBoundRouter. The page function calls
the same presenter:
// In your web entry point (e.g. src/main.js)
import { defineRoute } from "@forwardimpact/libui";
import { createHtmlFormatter } from "@forwardimpact/libformat";
import { presentStatus } from "./present-status.js";
import { formatStatus } from "./format-status.js";
const formatter = createHtmlFormatter();
// Register with the existing router:
router.register(defineRoute({
pattern: "/status/:service",
page: (ctx) => {
const view = presentStatus(ctx);
document.getElementById("app").innerHTML = formatStatus(view, formatter);
},
cli: (ctx) => `myapp status ${ctx.args.service}`,
}));
Navigate to #/status/api. The router extracts
{ service: "api" } as args. It
parses any query string into options. It freezes
everything into an InvocationContext. Then it calls
page(ctx).
The cli function is optional. When present, the command
bar displays the equivalent terminal command with a copy button.
Verify
The Step 1 presenter test already covers this. Both surfaces build
the same { data, args, options } shape. So the
presenter returns identical output, whichever surface produced the
context.
Checklist
-
Presenter depends only on
InvocationContext, with no DOM, no stdout, and no surface-specific imports - Format function accepts a formatter argument and does not create its own
-
CLI command declares
argswith named positionals and supports--json -
Web route
patternuses the same parameter names as the CLIargs -
Route descriptor includes a
clifunction so the command bar displays the terminal equivalent -
Presenter test passes with a
freezeInvocationContextfixture -
--jsonoutput matches the view object the presenter returns