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. Here's how to make it appear in both places from a single implementation.
Prerequisites
Complete the
Agent-Friendly Surfaces
guide first -- this page assumes a working
createCli definition, a createBoundRouter,
and the shared presenter pattern.
- Node.js 18+
- The three surface libraries installed:
npm install @forwardimpact/libcli @forwardimpact/libui @forwardimpact/libformat
Overview
Adding a capability follows four steps, each producing 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 -- the
same helper both surfaces use 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);
Because the context is frozen and surface-neutral, a passing test 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);
}
The formatter is injected 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));
}
},
}
Running myapp status api produces formatted terminal
output. Running myapp status api --json produces:
{
"name": "API Gateway",
"healthy": true,
"uptime": "14d 3h",
"version": "2.1.0"
}
Agents pass --json to get structured output without
parsing 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}`,
}));
Navigating to #/status/api extracts
{ service: "api" } as args,
parses any query string into options, freezes
everything into an InvocationContext, and 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 regardless of which surface
produced the context.
Checklist
-
Presenter depends only on
InvocationContext-- no DOM, no stdout, no surface-specific imports - Format function accepts a formatter argument -- 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 returned by the presenter
What's next
- Agent-Friendly Surfaces -- return to the full guide for the shared contract, query string parsing, and formatter details