mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-22 03:02:35 +00:00
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:
parent
f74add8271
commit
d4efc08909
5 changed files with 300 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"><your-api-token></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;
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue