Surface agent integrations in Settings → API Access

The agent substrate has been entirely API-side until now. An
operator opening Pulse had no way to know it existed, no way to
see what an agent connected to their instance could do, and no
quick path to a working MCP integration. Slice 59 closes that
visibility gap.

A new AgentIntegrationsPanel renders below the existing
APITokenManager on the API Access tab. The panel:

- Fetches /api/agent/capabilities at mount and renders the
  declared capabilities grouped by category (Context, Operator
  state, Patrol findings, Action governance), each row showing
  name, method+path, scope chip, description, and the stable
  error codes the manifest declares. Adding a capability on the
  backend extends this list automatically; nothing in the panel
  is hardcoded against the substrate's surface.

- Generates an MCP config snippet using window.location.origin so
  the snippet is correct for whichever URL the operator is
  reading from. Includes a copy-to-clipboard button (matching
  the existing CopyCommandBlock pattern) and a brief explainer
  pointing at the right config path for Claude Desktop and
  Claude Code.

- Links to cmd/pulse-mcp/README.md, cmd/agent-probe, and
  docs/AGENT_SUBSTRATE.md so an integrator has the full setup
  story without leaving the panel.

Wiring is one import + one component placement in
APIAccessPanel.tsx. The api tab already concerns "what can be
done with API tokens"; adding the agent surface as a sibling
section under the same tab keeps the operator's mental model
coherent (one place for machine-driven access) and avoids
growing the Settings tab inventory or touching
settingsNavigationModel, the registry, the loaders, or the
routing tests.

Two architectural pins land alongside:

- settingsArchitecture.test.ts gains a guardrail that the
  AgentIntegrationsPanel sits as a sibling section inside
  APIAccessPanel rather than being lifted into its own tab.
  Drift would fragment the agent surface across navigation.
- The frontend-primitives contract documents the
  sibling-panel-over-new-tab pattern for additive operator
  surfaces closely related to an existing tab's intent.
- The security-privacy contract documents the new section's
  presence on the API Access tab and pins that token minting
  still flows through APITokenManager — the new section
  surfaces what tokens unlock, not a parallel auth path.

Verified against the running dev server: the page renders the
four category sections (Context, Operator state, Patrol
findings, Action governance), the live MCP config snippet
contains the deployment's own origin, and the manifest endpoint
serves all 14 capabilities (the 11 substrate capabilities + the
3 action capabilities from slice 58). TypeScript clean across
the frontend.
This commit is contained in:
rcourtman 2026-05-10 16:35:40 +01:00
parent f74add8271
commit d4efc08909
5 changed files with 300 additions and 0 deletions

View file

@ -313,6 +313,19 @@ prompt explain the same operator-facing priority.
shortcut chip.
2. Route new top-level settings surfaces through the canonical settings shell
instead of introducing page-local framing.
When a new operator-facing concern is closely related to an
existing tab's intent, prefer adding it as a sibling
`SettingsPanel` inside that tab's container component over
minting a new top-level tab. The agent-integrations surface
added in slice 59 follows this pattern:
`frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx`
ships under the existing API Access tab via
`APIAccessPanel.tsx`'s composition, not as its own tab. This
keeps the tab inventory bounded, avoids touching
`settingsNavigationModel.ts`, the registry, the loaders, and
the routing tests for additive sub-surfaces, and presents
"tokens + what those tokens unlock" as one operator-facing
story.
Shared shells and primitives that need websocket or dark-mode context must
consume `frontend-modern/src/contexts/appRuntime.ts`; they must not import
`frontend-modern/src/App.tsx`, because `App.tsx` owns provider placement

View file

@ -278,6 +278,22 @@ helper. Security surfaces may consume compatibility-normalized
`platformType: 'truenas'` resources, but they must not reintroduce a separate
`resource.type === 'truenas'` trust path when calculating token usage,
revocation targets, or operator-facing token ownership.
The API Access tab now hosts an Agent Integrations section
(`frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx`)
alongside the existing API Token Manager. The section reads
`/api/agent/capabilities` at mount and renders the declared
agent surface (capabilities grouped by category, stable error
codes, scopes) plus an MCP config snippet generated from the
deployment's own origin so an operator wiring Claude Desktop or
Claude Code sees the right base URL automatically. The section
does NOT introduce a new token-mint flow or auth path: tokens
still flow through the API Token Manager, and the snippet
documents the `monitoring:read` / `monitoring:write` scopes the
agent surface requires. Keeping token minting and agent-surface
disclosure on the same tab means an operator gets a single
"machine-driven access" mental model rather than two surfaces
to reconcile.
That same token-management boundary also reserves token-owner identity for the
server-authenticated principal. Token-minting helpers must derive
`owner_user_id` from the authenticated session or caller token and reject any

View file

@ -3,6 +3,7 @@ import SettingsPanel from '@/components/shared/SettingsPanel';
import { API_TOKEN_ACCESS_PANEL_DESCRIPTION } from '@/utils/apiTokenPresentation';
import { API_TOKEN_SCOPES_DOC_URL } from '@/utils/docsLinks';
import APITokenManager from './APITokenManager';
import AgentIntegrationsPanel from './AgentIntegrationsPanel';
interface APIAccessPanelProps {
currentTokenHint?: string;
@ -37,6 +38,8 @@ export const APIAccessPanel: Component<APIAccessPanelProps> = (props) => {
refreshing={props.refreshing}
canManage={props.canManage}
/>
<AgentIntegrationsPanel />
</div>
);
};

View file

@ -0,0 +1,255 @@
import { Component, createMemo, createResource, createSignal, For, Show } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import CopyCommandBlock from './CopyCommandBlock';
// AgentCapability mirrors the wire shape of one entry in
// /api/agent/capabilities. Defined inline so this component does
// not depend on a shared API client; the manifest endpoint is
// public and stable.
interface AgentCapability {
name: string;
description: string;
category: string;
method: string;
path: string;
scope: string;
responseShape?: string;
errorCodes?: string[];
requestBodyShape?: string;
}
interface AgentCapabilitiesManifest {
version: string;
capabilities: AgentCapability[];
}
// Category presentation order on screen. Matches the closed set
// the backend pin enforces (see TestAgentCapabilitiesManifest_
// CategoriesAreClosed). Capabilities with an unknown category are
// rendered last under a generic "other" heading so a future
// addition that the panel does not yet know about still renders.
const CATEGORY_ORDER: ReadonlyArray<{ id: string; label: string; description: string }> = [
{
id: 'context',
label: 'Context',
description: 'Discovery and read-only situated reads. Agents start here.',
},
{
id: 'operator-state',
label: 'Operator state',
description: 'Per-resource intent: intentionally-offline, never-auto-remediate, maintenance windows.',
},
{
id: 'finding',
label: 'Patrol findings',
description: 'Acknowledge, snooze, dismiss, or resolve findings the patrol runtime raised.',
},
{
id: 'action',
label: 'Action governance',
description: 'Plan, approve, and execute capability invocations against a resource.',
},
];
async function fetchManifest(): Promise<AgentCapabilitiesManifest> {
const response = await fetch('/api/agent/capabilities', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`manifest fetch failed: ${response.status}`);
}
return (await response.json()) as AgentCapabilitiesManifest;
}
// formatMcpConfig builds the JSON snippet an integrator drops into
// claude_desktop_config.json (or .mcp.json for Claude Code). The
// host is the deployment's own origin so the snippet is correct
// for whatever URL the operator is reading the panel from.
function formatMcpConfig(origin: string): string {
const config = {
mcpServers: {
pulse: {
command: 'pulse-mcp',
args: ['--base-url', origin],
env: {
PULSE_API_TOKEN: '<your-api-token>',
},
},
},
};
return JSON.stringify(config, null, 2);
}
const AGENT_PROBE_README = 'https://github.com/rcourtman/Pulse/blob/main/cmd/agent-probe/main.go';
const PULSE_MCP_README = 'https://github.com/rcourtman/Pulse/blob/main/cmd/pulse-mcp/README.md';
const SUBSTRATE_DOC = 'https://github.com/rcourtman/Pulse/blob/main/docs/AGENT_SUBSTRATE.md';
export const AgentIntegrationsPanel: Component = () => {
const [manifest] = createResource(fetchManifest);
const [copied, setCopied] = createSignal<string | null>(null);
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:7655';
const mcpConfig = formatMcpConfig(origin);
const grouped = createMemo(() => {
const m = manifest();
if (!m) return [];
// Index capabilities by category, then return them in
// CATEGORY_ORDER. Unknown categories spill into a generic
// "other" bucket so the panel never silently drops a
// capability the operator should be able to see.
const byCategory = new Map<string, AgentCapability[]>();
for (const cap of m.capabilities) {
const list = byCategory.get(cap.category) ?? [];
list.push(cap);
byCategory.set(cap.category, list);
}
const sections: Array<{ id: string; label: string; description?: string; entries: AgentCapability[] }> = [];
for (const known of CATEGORY_ORDER) {
const entries = byCategory.get(known.id);
if (entries && entries.length > 0) {
sections.push({ id: known.id, label: known.label, description: known.description, entries });
byCategory.delete(known.id);
}
}
for (const [unknownCategory, entries] of byCategory) {
sections.push({ id: unknownCategory, label: unknownCategory, entries });
}
return sections;
});
const handleCopySnippet = async (snippet: string) => {
await navigator.clipboard.writeText(snippet);
setCopied(snippet);
window.setTimeout(() => setCopied(null), 2000);
};
return (
<SettingsPanel title="Agent integrations" noPadding>
<div class="space-y-5 p-4 sm:p-6">
<div class="space-y-2 text-sm text-muted">
<p>
Pulse exposes a manifest-driven agent surface for MCP and HTTP clients. An external
agent (Claude Desktop, Claude Code, or a custom integration) discovers what it can do
via <code class="font-mono text-xs">/api/agent/capabilities</code>, then calls the
declared endpoints with an API token.
</p>
<p>
The capabilities below are read live from this Pulse instance. Adding a capability on
the backend adds it here automatically; nothing in this panel is hardcoded.
</p>
</div>
<div class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-sm font-semibold text-base-content">Claude Desktop / Claude Code config</h3>
<Show when={copied() === mcpConfig}>
<span class="text-xs text-emerald-600 dark:text-emerald-400">Copied to clipboard</span>
</Show>
</div>
<p class="text-xs text-muted">
Drop this block into{' '}
<code class="font-mono">~/Library/Application Support/Claude/claude_desktop_config.json</code>{' '}
(Claude Desktop) or your project's <code class="font-mono">.mcp.json</code> (Claude
Code). Mint a token below with <code class="font-mono">monitoring:read</code> (and{' '}
<code class="font-mono">monitoring:write</code> for the operator-state write tools), then
replace <code class="font-mono">&lt;your-api-token&gt;</code>.
</p>
<CopyCommandBlock
command={mcpConfig}
onCopy={handleCopySnippet}
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
/>
<p class="text-xs text-muted">
See{' '}
<a class="text-blue-600 hover:underline dark:text-blue-300" href={PULSE_MCP_README} target="_blank" rel="noreferrer">
cmd/pulse-mcp/README.md
</a>{' '}
for build instructions, the <code class="font-mono">--emit-notifications</code> flag,
and known limitations. The companion HTTP example is at{' '}
<a class="text-blue-600 hover:underline dark:text-blue-300" href={AGENT_PROBE_README} target="_blank" rel="noreferrer">
cmd/agent-probe
</a>
. Background and the substrate's design notes live in{' '}
<a class="text-blue-600 hover:underline dark:text-blue-300" href={SUBSTRATE_DOC} target="_blank" rel="noreferrer">
docs/AGENT_SUBSTRATE.md
</a>
.
</p>
</div>
<Show when={manifest.loading}>
<p class="text-sm text-muted">Loading capabilities</p>
</Show>
<Show when={manifest.error}>
<p class="text-sm text-red-600 dark:text-red-300">
Could not load <code class="font-mono">/api/agent/capabilities</code>:{' '}
{String(manifest.error)}
</p>
</Show>
<Show when={manifest() && grouped().length > 0}>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-base-content">
Declared capabilities (manifest {manifest()?.version})
</h3>
<span class="text-xs text-muted">
{manifest()?.capabilities.length ?? 0} total
</span>
</div>
<For each={grouped()}>
{(section) => (
<div class="space-y-2">
<div>
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
{section.label}
</h4>
<Show when={section.description}>
<p class="text-xs text-muted">{section.description}</p>
</Show>
</div>
<ul class="divide-y divide-border rounded-md border border-border">
<For each={section.entries}>
{(cap) => (
<li class="space-y-1 p-3">
<div class="flex flex-wrap items-baseline gap-2">
<code class="font-mono text-sm font-semibold text-base-content">
{cap.name}
</code>
<span class="text-xs text-muted">
<code class="font-mono">{cap.method}</code> {cap.path}
</span>
<span class="ml-auto rounded-full border border-border bg-surface-alt px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted">
{cap.scope}
</span>
</div>
<p class="text-xs text-muted">{cap.description}</p>
<Show when={cap.errorCodes && cap.errorCodes.length > 0}>
<p class="text-[11px] text-muted">
<span class="font-semibold">Stable error codes:</span>{' '}
<For each={cap.errorCodes}>
{(code, idx) => (
<>
<code class="font-mono">{code}</code>
{idx() < (cap.errorCodes!.length - 1) ? ', ' : ''}
</>
)}
</For>
</p>
</Show>
</li>
)}
</For>
</ul>
</div>
)}
</For>
</div>
</Show>
</div>
</SettingsPanel>
);
};
export default AgentIntegrationsPanel;

View file

@ -635,6 +635,19 @@ describe('settings architecture guardrails', () => {
);
});
it('exposes the agent integrations panel under API Access without growing the settings tab inventory', () => {
// The agent integrations panel sits as a sibling section
// under the existing API Access tab rather than as its own
// navigation entry. This pin keeps the operator's mental
// model coherent (one tab for machine-driven access:
// tokens + what those tokens unlock) and prevents drift
// toward fragmenting the agent surface across tabs.
expect(apiAccessPanelSource).toContain(
"import AgentIntegrationsPanel from './AgentIntegrationsPanel';",
);
expect(apiAccessPanelSource).toContain('<AgentIntegrationsPanel />');
});
it('keeps internal analytics off the user diagnostics boundary', () => {
expect(diagnosticsResultsPanelSource).not.toContain('Commercial Funnel');
expect(diagnosticsResultsPanelSource).not.toContain('Infrastructure Onboarding');