@xtrape/capsule-agent-node
v0.4.0
Published
Node.js Agent SDK for embedding Capsule Services into the Opstage governance loop.
Maintainers
Readme
@xtrape/capsule-agent-node
Node.js embedded Agent SDK for connecting Capsule Services to Opstage.
@xtrape/capsule-agent-node supports the stable Node.js Embedded Agent SDK path for single Capsule Services. For multi-service OpHub functionality, use OpHub (Go runtime).
Package status: Xtrape Capsule is currently in Public Review before the
v0.1.0 Public Previewrelease. This package is published under thepublic-reviewdist-tag. APIs, contracts, deployment instructions, and SDK interfaces may still change.
Install
During Public Review, install the prerelease package with:
pnpm add @xtrape/capsule-agent-node@public-reviewThe current Public Review version may change before v0.1.0.
During v0.3 development, this repository may use a local workspace, GitHub
branch dependency, or 0.3.0-rc.x package for
@xtrape/capsule-contracts-node until the final 0.3.0 package is published.
For this repository itself:
pnpm install
pnpm buildMinimal Example
This example matches the current SDK API: create a CapsuleAgent, configure
providers with .health() / .configs(), register Actions with .action(),
then call start().
import { CapsuleAgent } from "@xtrape/capsule-agent-node";
const agent = new CapsuleAgent({
backendUrl: process.env.OPSTAGE_BACKEND_URL ?? "http://localhost:8080",
registrationToken: process.env.OPSTAGE_REGISTRATION_TOKEN,
tokenStore: { file: "./data/agent-token.txt" },
// Optional. If omitted, the SDK derives agent identity from `service`.
// Provide it explicitly when one Agent owns multiple Capsule Services on
// the same host, or when you want a stable agent code that survives
// service renames.
agent: {
code: "hello-capsule-agent",
name: "Hello Capsule Agent",
runtime: "nodejs",
},
service: {
code: "hello-capsule",
name: "Hello Capsule",
version: "0.1.0",
runtime: "nodejs",
description: "Minimal Capsule Service example",
},
});
agent.health(() => ({
status: "UP",
message: "ok",
details: {
uptimeSeconds: Math.floor(process.uptime()),
},
}));
agent.configs(() => [
{
key: "HELLO_MODE",
label: "Hello mode",
type: "string",
editable: false,
sensitive: false,
valuePreview: process.env.HELLO_MODE ?? "default",
},
]);
agent.action({
name: "echo",
label: "Echo",
description: "Return the submitted message.",
dangerLevel: "LOW",
requiresConfirmation: false,
inputSchema: {
type: "object",
required: ["message"],
properties: {
message: { type: "string", default: "hello" },
},
},
prepare: () => ({
initialPayload: { message: "hello" },
currentState: { service: "ready" },
}),
handler: async (payload) => ({
success: true,
data: { echo: payload.message },
}),
});
await agent.start();How Registration Works
- An operator creates a single-use Registration Token in Opstage CE.
- The service starts with
registrationTokenand calls the Agent registration API. - Opstage returns an Agent ID and Agent token.
- The SDK stores the issued credentials in
tokenStore.fileas<agentId>:<agentToken>. - Future restarts reuse the stored Agent token; the Registration Token is not needed again.
If the token file is lost or the Agent is revoked, create a new Registration Token and restart the service with it.
Service Manifest
The service option describes the Capsule Service reported to Opstage:
service: {
code: "hello-capsule", // stable unique service code
name: "Hello Capsule", // operator-facing display name
description: "...", // optional
version: "0.1.0", // service version
runtime: "nodejs", // nodejs | java | python | go | other
manifest: { labels: { team: "ai" } }, // optional passthrough metadata
}The SDK wraps this as a CapsuleService manifest with kind: "CapsuleService",
schemaVersion: "1.0", and agentMode: "embedded".
Health Reporting
Use .health(provider) to report protocol-level health:
agent.health(async () => ({
status: "UP", // UP | DEGRADED | DOWN | UNKNOWN
message: "queue healthy",
details: { queueDepth: 0 },
}));The provider runs for heartbeats and service reports. Do not include secrets in
details.
Agent health providers return protocol-level HealthStatus values: UP,
DEGRADED, DOWN, UNKNOWN.
Opstage may derive an operator-facing effectiveStatus: HEALTHY, UNHEALTHY,
STALE, OFFLINE.
Config Reporting
Use .configs(provider) to report observed config metadata:
agent.configs(() => [
{
key: "UPSTREAM_BASE_URL",
type: "string",
sensitive: false,
editable: false,
valuePreview: process.env.UPSTREAM_BASE_URL,
},
{
key: "UPSTREAM_API_KEY",
type: "secret",
sensitive: true,
editable: false,
valuePreview: "[REDACTED]",
secretRef: "env://UPSTREAM_API_KEY",
},
]);Configs are reported to Opstage for visibility; the current CE flow does not push config values from Opstage into the service.
Actions
Use .action() to expose an operator-triggerable operation:
agent.action({
name: "reload-cache",
label: "Reload Cache",
dangerLevel: "MEDIUM",
requiresConfirmation: true,
timeoutSeconds: 30,
handler: async () => {
await reloadCache();
return { success: true, message: "Cache reloaded." };
},
});Action metadata is published in the service report. The SDK intentionally strips runtime-only handler functions before reporting the Action Catalog.
Action Prepare / Execute
Opstage uses two Command types for actions:
ACTION_PREPARE— created when the UI opens an Action panel. The SDK callsprepare()if present and returns dynamic form metadata such asinputSchema,initialPayload, andcurrentState.ACTION_EXECUTE— created when the operator confirms the Action. The SDK callshandler(payload)and reports the result.
If an Action has no prepare handler, the SDK returns a default prepare payload
based on the action metadata and inputSchema defaults.
Command Polling
The embedded Agent starts three loops by default:
| Loop | Default | Purpose | | -------------- | ---------: | ---------------------------------- | | Heartbeat | 30 seconds | Agent liveness and latest health | | Service report | 60 seconds | Manifest, configs, actions, health | | Command poll | 5 seconds | Fetch and execute pending Commands |
You can override intervals:
new CapsuleAgent({
// ...
intervals: {
heartbeatMs: 30_000,
serviceReportMs: 60_000,
commandPollMs: 5_000,
},
});For tests, set autoStartLoops: false and call start() to perform one
registration/report/heartbeat/poll cycle.
Token Storage
The default token store is file-based via FileTokenStore. Store the token file
in a private data directory:
new CapsuleAgent({
// ...
tokenStore: { file: "./data/agent-token.txt" },
});Security recommendations:
- chmod the containing directory so only the service user can read it;
- never commit token files;
- rotate by revoking the Agent in Opstage and registering a new one;
- prefer secret managers for production wrappers when available.
Security Notes
- Registration Tokens are single-use bootstrap credentials.
- Agent Tokens are long-lived bearer tokens; treat them as secrets.
- Actions are remote operational capabilities. Use
dangerLevel,requiresConfirmation, and server-side validation in handlers. - Do not report raw passwords, API keys, cookies, OTPs, browser storage, or session files through health/config/action results.
- Logs are redacted by the SDK where possible, but service handlers remain responsible for avoiding secret leakage.
API Reference
Main exports:
CapsuleAgentFileTokenStoreAgentApiClientAgentApiError,RegistrationError,AgentAuthError,NetworkErrorAgentLogLevel,AgentLogRecord,StructuredLogger,ConsoleLogger(types)- SDK option and provider types from
types.ts
Core methods:
| Method | Description |
| --------------------------- | ------------------------------------------------------------- |
| new CapsuleAgent(options) | Creates the embedded Agent. |
| .health(provider) | Registers a health provider. |
| .configs(provider) | Registers a config provider. |
| .action(action) | Registers an operator Action. |
| .start() | Registers if needed, reports service state, and starts loops. |
| .stop() | Stops background loops. |
| .runHealth() | Runs the configured health provider. |
Typed errors
Added in
0.2.0. Pre-0.2code that catchesAgentApiErrorstill works because the new classes extend it.
The SDK surfaces four error classes so callers can branch on the failure mode without parsing messages:
| Class | When it's thrown | Should the caller retry? |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| RegistrationError | /api/agents/register rejects the one-time token (expired, revoked, already used, malformed). Needs operator action — mint a fresh registration token. | No. |
| AgentAuthError | Any non-register call returns 401/403. The agent token has been revoked or the agent disabled. Re-register before resuming. | No. |
| NetworkError | fetch() threw before getting an HTTP response (DNS failure, ECONNREFUSED, abort, timeout). status === 0; carries an optional cause. | Yes — SDK already does. |
| AgentApiError | Catch-all parent. Direct instances carry HTTP 4xx/5xx that don't fit the more specific cases (e.g. 409 conflict, 422 validation, 5xx server error). | Only on 5xx. |
The SDK's internal retry() loop already implements this policy: only
NetworkError and 5xx AgentApiError are retried with exponential backoff;
4xx — including the two typed subclasses — surfaces immediately so the caller
isn't waiting on a backoff that can never succeed.
import {
CapsuleAgent,
RegistrationError,
AgentAuthError,
NetworkError,
} from "@xtrape/capsule-agent-node";
try {
await agent.start();
} catch (err) {
if (err instanceof RegistrationError) {
console.error("registration token is no longer valid:", err.code);
process.exit(64);
}
if (err instanceof AgentAuthError) {
console.error("agent token rejected — was the agent revoked?");
process.exit(65);
}
if (err instanceof NetworkError) {
console.error("could not reach Opstage:", err.message);
process.exit(75);
}
throw err;
}Structured logger
Added in
0.2.0. Existing zero-config callers (nologger/ nostructuredLogger) keep using the globalconsoleexactly as before.
By default the SDK calls console.debug / .info / .warn / .error. For
JSON-line logging, pino, OpenTelemetry, or any in-house collector, pass a
structuredLogger:
import { CapsuleAgent, type StructuredLogger } from "@xtrape/capsule-agent-node";
const collector: StructuredLogger = {
log({ level, message, fields }) {
process.stdout.write(JSON.stringify({ t: Date.now(), level, message, ...fields }) + "\n");
},
};
const agent = new CapsuleAgent({
// ...
structuredLogger: collector,
});Each record arrives as { level: "debug" | "info" | "warn" | "error",
message: string, fields?: Record<string, unknown> }. The SDK normalizes the
optional context before emitting:
Errorinstances become{ error: { name, message, stack } }so they surviveJSON.stringify.- Primitives become
{ value: <primitive> }. - Plain objects pass through unchanged.
In all cases the redactor runs over fields first, so tokens and
secret-bearing keys never reach the collector. If both structuredLogger
and logger are configured, structuredLogger wins.
Version Compatibility
| Package | Compatible with |
| ---------------------------------- | ------------------------------------------------------------- |
| @xtrape/[email protected] | Opstage CE 0.1.x and @xtrape/[email protected] |
Use matching minor versions across CE, Agent SDK, and Contracts during Public
Review and Public Preview. The wire protocol may still change before v1.0.
Troubleshooting
Registration fails with 401 / REGISTRATION_TOKEN_INVALID
The token is single-use and short-lived.
- Check the token has not already been consumed by an earlier successful start
(registration tokens flip to
USEDon first use). - Check the token has not expired. Operators set
expiresInSecondswhen creating the token; the default in CE is short. - Check the token has not been revoked from the Opstage console.
- Re-create a fresh token in the console and start the agent with it.
Agent reports ECONNREFUSED / cannot reach backend
- Confirm
OPSTAGE_BACKEND_URLresolves from inside the container or host where the agent runs. The default ofhttp://localhost:8080only works if the agent runs on the same host as Opstage. - If you sit behind a reverse proxy, point
backendUrlat the proxy URL — not the internal Opstage container address. - Outbound HTTPS through corporate proxies: respect
HTTPS_PROXY/NO_PROXYenv vars (undicihonors them viasetGlobalDispatcher).
Agent token file is rejected on second start (401 / UNAUTHORIZED)
- File permissions: the agent process must be able to read
tokenStore.file. Runchmod 600(owner read/write) and ensure the process owner matches. - File contents: the SDK writes
<agentId>:<agentToken>plaintext. If the file exists but contains anything else, delete it and re-register with a fresh registration token. - Token revocation: an operator may have revoked the agent or the agent
token from the console. Inspect the agent's status in Opstage; if it shows
REVOKEDorDISABLED, that's the cause.
Heartbeats succeed but action results never arrive
- Confirm the action
nameregistered withagent.action({ name: "..." })matches theactionNamethe backend dispatches in the command. The SDK reportsACTION_HANDLER_NOT_FOUNDif they differ. - Confirm
start()is awaited and not called repeatedly — multipleCapsuleAgentinstances on the same host with the same agent code will fight over heartbeats and command polls. - Check
commandPollMs/commandPollIntervalSecondsis not absurdly large; default is 5s.
Documentation
- Site: https://xtrape-com.github.io/xtrape-capsule-site/
- Node embedded Agent guide: https://xtrape-com.github.io/xtrape-capsule-site/agents/node-embedded-agent
- Action model: https://xtrape-com.github.io/xtrape-capsule-site/agents/action-model
- Opstage CE: https://xtrape-com.github.io/xtrape-capsule-site/opstage-ce/overview
Contributing
See CONTRIBUTING.md for development workflow and PR checks. See SECURITY.md for vulnerability reporting and token/action safety guidance.
License
Apache-2.0. "Xtrape", "Xtrape Capsule", and "Opstage" are trademarks of their respective owners; the open-source license does not grant trademark rights.
v0.4 Experimental Capsule Bus Hook
The SDK exposes a minimal experimental publish hook into CE's built-in SQLite-backed in-process event-to-command router:
await agent.publishBusEvent({
eventType: "demo.item.created",
payload: { itemId: "item-1" },
});The hook posts to Opstage CE as the registered embedded agent and defaults sourceServiceCode to the configured service code.
Typed errors for Bus failure modes:
BusDisabledError—CAPSULE_BUS_DISABLED(404)BusRateLimitedError—BUS_RATE_LIMITED(429)BusDepthExceededError—BUS_DEPTH_EXCEEDED(422)
Capsule Bus is experimental in v0.4: not a standalone Bus Server, external broker, workflow engine, or service mesh API.
