Closes the documented limitation in slice 51's pulse-mcp: MCP clients that process server-initiated notifications can now react to Pulse's push channel without holding a separate HTTP connection to /api/agent/events. The bridge is opt-in via --emit-notifications because not every MCP client surfaces arbitrary notifications/* methods (Claude Desktop, today, does not). Autonomous agents that consume the JSON-RPC stream programmatically benefit; UI-mediated clients should keep the flag off and use the SSE stream directly. Implementation: a long-lived goroutine, started once after the first initialize, that opens /api/agent/events, parses the substrate's wire format, and emits a JSON-RPC notification per non-transport event. Method names mirror the SSE event kinds (notifications/finding.created, notifications/approval. pending, notifications/action.completed). Params is the SSE data payload verbatim so agents see the same wire shape an HTTP SSE consumer would. stream.connected and heartbeat are filtered as transport plumbing. The consumer reconnects with capped exponential backoff on transient errors. When --emit-notifications is on, initialize advertises the supported event kinds under capabilities.experimental.pulseNotifications.kinds. Clients that don't understand the experimental block ignore it silently. Three tests pin the behaviour: the initialize handshake's capability block is correctly gated on the flag; the notification filter rejects transport events and accepts the three substrate kinds; an httptest.NewServer-backed end-to-end translates a multi-event SSE stream into JSON-RPC notifications with the substrate's payload preserved. Also flagged in AGENT_SUBSTRATE.md "what it does not do yet": the action-execution endpoints (/api/actions/plan, decision, execute) emit a different error envelope from the agent surface (APIError with stable code under "code") versus the agent-stable shape (stable code under "error"). Adding them to the manifest requires resolving that mismatch first; recorded as a focused slice for whenever the substrate's reach extends to direct agent-driven execution.
5.9 KiB
Pulse agent substrate
A short, plain-English summary of what landed across the agent-paradigm
arc on pulse/v6-release. Suitable as the basis for release notes, a
GitHub announcement, or just a reminder to yourself in three weeks of
what shape this work took.
What it is
Pulse v6 ships an agent-paradigm substrate so external agents (Claude Desktop, Claude Code, custom MCP clients, plain HTTP consumers) can drive Pulse with the same context an in-process Patrol or Assistant has. The substrate has four axes:
Discovery. A hand-authored manifest at /api/agent/capabilities
lists every agent-consumable capability with its name, description,
HTTP method and path, required auth scope, response shape, and stable
error codes. The manifest is unauthenticated so an agent without a
token can introspect Pulse before asking for one.
Depth. /api/agent/resource-context/{id} returns the situated
picture of one resource in a single read: identity, operator-set
state, active findings, pending approvals, recent actions including
refused dispatches and verification probe outcomes. Stable token
prefixes (plan_drift:, resource_remediation_locked:) reach the
wire verbatim so agents branch on codes, not human text.
Breadth. /api/agent/fleet-context returns a thin per-resource
rollup across the whole org: identity, operator flags, per-severity
finding counts, pending-approval count. One read for "where do I
focus?", with the per-resource bundle for follow-up depth.
Write. The operator-state intent loop
(/api/resources/{id}/operator-state) lets an agent record
per-resource commitments (intentionally offline, never auto-remediate,
maintenance window, criticality). The server populates attribution so
client values cannot spoof who-did-it. Validation failures emit the
operator_state_invalid stable code; reads on unset resources emit
operator_state_not_set.
Push. /api/agent/events is an SSE stream that fires
finding.created, approval.pending, and action.completed events
as state changes. Each event is a small fixed-shape payload with
enough context for an agent to decide whether to follow up. Refused
dispatches preserve their stable error tokens; successful dispatches
carry a verification block so agents close the certainty loop without
polling the audit endpoint.
What ships consuming it
Two reference consumers, both standard-library-only, both manifest-driven:
-
cmd/agent-probeis a small Go binary that walks the discovery, triage, depth, push flow against a running Pulse instance. Useful as a smoke test or worked example for someone building their own integration. -
cmd/pulse-mcpis the MCP server adapter. Wire it into Claude Desktop or Claude Code per the README atcmd/pulse-mcp/README.mdand Pulse's tools appear natively. The adapter projects each manifest capability into one MCP tool with auto-derived input schema; adding capabilities to Pulse extends the MCP surface without changes in the adapter. Run with--emit-notificationsto also translate Pulse's SSE events (finding.created,approval.pending,action.completed) into JSON-RPC notifications on the stdio channel so autonomous MCP-bound agents can react to push events without holding a separate HTTP connection.
What it does not do yet
- The substrate does not yet expose the governed action-execution
surface (
/api/actions/plan,/api/actions/{id}/decision,/api/actions/{id}/execute) as agent-stable manifest entries. Those handlers exist and are wired through the action audit store, but they emit a different error envelope from the agent surface (APIErrorshape: stable code undercode, human message undererror) versus the agent-stable shape (stable code undererror, human undermessage). Adding them to the manifest as-is would force agents to remember which envelope each capability uses. Resolving that mismatch is a focused slice of its own. Until then, action governance flows through the existing approval store and theaction.completedpush channel: the AI service plans, an operator (or operator-acting agent) approves, the substrate emits the event.
Provable claims
-
Manifest is honest. A contract pin (
TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations) parses everywriteJSONErrorcall from agent-surface handlers and everyErrorCodesdeclaration from the manifest, asserting symmetry both directions. Drift either way fails the test. -
The substrate composes. Two paired end-to-end tests in
internal/api/agent_substrate_e2e_test.goboot the full router stack and walk discovery, triage, depth, and the operator-state write loop through the actual HTTP boundary. They are the substantive proof that the four axes work as one. -
Discovery is unauthenticated. Pinned by
TestContract_AgentCapabilitiesManifestIsPublicafter a slice 47 fix added the path topublicPaths. Slice 40 had it 401'ing despite the docs. -
Stable error envelope is two-layer. Capability-specific codes (
resource_not_found,operator_state_not_set,operator_state_invalid) are declared per-capability in the manifest. Cross-cutting codes (invalid_org,org_suspended,access_denied) come from the auth and multi-tenant middleware and apply to every authenticated endpoint. Documented inapi-contracts.md; a contract test enforces no drift.
Where to read more
- Full contract:
docs/release-control/v6/internal/subsystems/api-contracts.md, agent-surface paragraphs in the## Current Statesection. - Implementation:
internal/api/agent_*.goandinternal/api/resources_operator_state.go. - MCP adapter:
cmd/pulse-mcp/(with README). - HTTP worked example:
cmd/agent-probe/. - Subsystem dependencies: relevant paragraphs in
agent-lifecycle.md,performance-and-scalability.md, andstorage-recovery.mdunderdocs/release-control/v6/internal/subsystems/.