@xtrape/capsule-agent-node
v0.7.1
Published
Node.js Embedded Agent SDK for reporting Workers to the Xtrape control plane.
Maintainers
Readme
Xtrape Embedded Agent SDK for Node.js
Node.js Embedded Agent SDK for connecting Workers to the Xtrape control plane.
The npm name @xtrape/capsule-agent-node, the CapsuleAgent export, and some
existing wire fields are legacy compatibility identifiers. Capsule is not a
current Xtrape concept. New integrations should use XtrapeAgent and the
worker option.
The package supports the Node.js Embedded Agent mode for one Worker. Multi-Worker ownership belongs to a sidecar or external Agent. Gateway, when present, routes Agent/control-plane traffic and does not replace the Agent role.
Package status: This package follows Xtrape milestone-aligned component versioning (
MINOR= milestone). The npm package name remains a compatibility identifier; the current milestone line is0.7.x.
Install
pnpm add @xtrape/capsule-agent-node@^0.7.0For this repository itself:
pnpm install
pnpm buildXtrape CE Phase 0 Service Agent
For the Xtrape CE 0.1 runtime loop (Telegram → xtrape-server-ce → xtrape-service
→ reply), use CeServiceAgent (or the serveCeService bootstrap). An
xtrape-service registers an instance to xtrape-server-ce, heartbeats, and
replies to ConversationMessages. Service and Worker are roles here, not the
legacy Capsule concept.
One-call bootstrap
serveCeService starts an HTTP listener (POST /messages, action endpoints,
GET /health),
registers the instance (sending the registration token if server-ce requires
one), stores the per-instance access token returned by server-ce, and runs
authenticated heartbeats — so a service does not copy-paste lifecycle code:
For deployed or containerized use, set address to the callback URL that
xtrape-server-ce can actually reach and allowlist, for example
http://demo-service:8080. The SDK only derives a default address when you
bind to an explicit local-only host such as 127.0.0.1.
import { serveCeService } from "@xtrape/capsule-agent-node";
const svc = await serveCeService({
serverUrl: process.env.XTRAPE_SERVER_URL ?? "http://localhost:3000",
registrationToken: process.env.SERVER_CE_REGISTRATION_TOKEN,
// Optional compatibility fallback for older server-ce deployments. Current
// server-ce returns a per-instance access token during registration, and the
// SDK uses that token first for heartbeat/deregister.
controlToken: process.env.SERVER_CE_CONTROL_TOKEN,
// Optional: require authenticated callbacks from server-ce on POST /messages
// and action endpoints (x-xtrape-service-callback-token or Authorization: Bearer).
callbackToken: process.env.SERVICE_CALLBACK_TOKEN,
port: 8080,
// required in deployed/containerized environments
address: process.env.SERVICE_ADDRESS ?? "http://demo-service:8080",
manifest: {
contractVersion: 1,
service: { id: "demo-echo-service", name: "Demo Echo Service", version: "0.7.0" },
runtime: { language: "node", agent: "xtrape-agent-nodejs" },
capabilities: ["status.query"],
message: { supportedTypes: ["QUERY"] },
},
handler: (msg) => `echo: ${msg.content}`,
});
process.on("SIGTERM", () => void svc.close()); // stop heartbeat + deregister + closeCE service actions
Pass actions to expose operator-triggerable operations over GET /actions,
POST /actions/:name/prepare, and POST /actions/:name/execute:
const svc = await serveCeService({
// ...as above
actions: [
{
name: "relogin",
label: "Re-login account",
// Optional two-phase prepare: fetch live defaults/current state before
// the operator fills the form (advertised as requiresPrepare: true).
prepare: async () => ({ initialPayload: { accountId: "acc-1" } }),
execute: async (input) => ({ ok: true }),
},
{
name: "full-sync",
label: "Full re-sync",
// Long-running action (server-ce#41 Stage B): when the pushed execute
// carries x-xtrape-command-id, the SDK replies 202 RUNNING immediately,
// runs execute in the background, and reports the outcome to server-ce
// with the per-instance access token. Against an older server-ce
// (no header) the action falls back to synchronous execution.
async: true,
execute: async (input) => runFullSync(input),
},
],
});Lower-level agent
When the service already runs its own HTTP server, use CeServiceAgent directly:
import { CeServiceAgent } from "@xtrape/capsule-agent-node";
const agent = new CeServiceAgent({
serverUrl: "http://localhost:3000",
registrationToken: process.env.SERVER_CE_REGISTRATION_TOKEN,
controlToken: process.env.SERVER_CE_CONTROL_TOKEN, // compatibility fallback only
address: "http://127.0.0.1:8080",
manifest: /* ServiceManifest */ undefined as never,
});
const stop = await agent.start(); // register + heartbeat
// in your POST /messages route:
const reply = await agent.handleMessage(requestJson, (m) => `echo: ${m.content}`);
// on shutdown:
await stop(); // stop heartbeat + deregisterMinimal Example
This example uses the canonical alias XtrapeAgent, configures
providers with .health() / .configs(), register Actions with .action(),
then call start().
import { XtrapeAgent } from "@xtrape/capsule-agent-node";
const agent = new XtrapeAgent({
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 `worker`.
// Provide it explicitly when one Agent owns multiple Workers on
// the same host, or when you want a stable agent code that survives
// service renames.
agent: {
code: "hello-worker-agent",
name: "Hello Worker Agent",
runtime: "nodejs",
},
worker: {
code: "hello-worker",
name: "Hello Worker",
version: "0.1.0",
runtime: "nodejs",
description: "Minimal Worker 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 credential in Panel CE (the current API calls it a Registration Token).
- The Worker starts with
registrationTokenand calls the Agent registration API. - The control plane returns an Agent ID and Agent credential (currently named an 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.
Worker Manifest
The worker option describes the Worker reported to the control plane. The old
service option remains accepted for compatibility:
worker: {
code: "hello-worker", // stable unique Worker code
name: "Hello Worker", // 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 Worker manifest with kind: "Worker",
schemaVersion: "1.0", and agentMode: "embedded". Existing Panel versions
continue accepting it through the legacy reported-service wire envelope.
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 Worker Runtime Reports. Do not include secrets in
details.
Agent health providers return protocol-level HealthStatus values: UP,
DEGRADED, DOWN, UNKNOWN.
Xtrape Panel 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",
},
]);Config Definitions and safe observations are reported to Panel for visibility. The Worker or deployment platform remains the configuration owner; current CE does not push arbitrary values into the Worker.
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 Worker Runtime Report. The SDK intentionally strips runtime-only handler functions before reporting the Action Catalog.
Action Prepare / Execute
Xtrape Panel 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 | | Worker report | 60 seconds | Manifest, config observations, Actions, health | | Command poll | 5 seconds | Fetch and execute pending Commands |
You can override intervals:
new CapsuleAgent({
// ...
intervals: {
heartbeatMs: 30_000,
workerReportMs: 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 Xtrape Panel 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 Xtrape Panel:", 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 |
| ---------------------------------- | ------------------------------------------------------------- |
| repository line 0.7.x | Xtrape 0.7 milestone (xtrape-server-ce >= 0.7.0, @xtrape/capsule-contracts-node@^0.7.0) |
| @xtrape/[email protected] | Xtrape Panel CE 0.1.x and @xtrape/[email protected] |
Pin matching milestone (MINOR) versions across CE, Agent SDK, and Contracts;
patch numbers may differ per component. 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 Xtrape Panel 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 Xtrape Panel. - If you sit behind a reverse proxy, point
backendUrlat the proxy URL — not the internal Xtrape Panel 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 Xtrape Panel; 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
- Public docs repo: https://forgejo.xtrape.com/xtrape/xtrape-site
- Node embedded Agent guide: https://forgejo.xtrape.com/xtrape/xtrape-site/src/branch/main/docs/agents/node-embedded-agent.md
- Action model: https://forgejo.xtrape.com/xtrape/xtrape-site/src/branch/main/docs/agents/action-model.md
- Xtrape Panel CE docs: https://forgejo.xtrape.com/xtrape/xtrape-site/src/branch/main/docs/opstage-ce/overview.md
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" and "Opstage" are trademarks of their respective owners; the open-source license does not grant trademark rights.
v0.4 Experimental legacy event routing 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 Xtrape Panel 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)
legacy event routing is experimental in v0.4: not a standalone Bus Server, external broker, workflow engine, or service mesh API.
