@cots/sample
v2.0.0
Published
Cots.ai agent interceptor SDK — configure once, then every governed action is intercepted, evaluated, held for approval if needed, and proven in the audit ledger.
Readme
@cots/sample
Node.js / TypeScript SDK for the Cots.ai Agent Action Firewall & Compliance Audit Gateway. Configure
once with your organization's tenantId, your agent's agentId, and this agent's own API
key/secret, then wrap every real-world action your agent takes in guard() — it calls the
interceptor first and only runs your code if the action is allowed (or gets approved by a human).
Installation
npm install @cots/sampleQuick start
Minimal setup: put your four values (plus, optionally, the gateway URL) in .env, load them with
configFromEnv(), and hand them to createCotsClient():
# .env
COTS_TENANT_ID=ten_xxxxxxxxxx
COTS_AGENT_ID=agt_xxxxxxxxxx
COTS_API_KEY=capp_key_xxx
COTS_API_SECRET=capp_secret_xxx
COTS_ACTION_GATEWAY_API_URL=http://localhost:8083/apiimport { configFromEnv, createCotsClient } from '@cots/sample';
const cots = createCotsClient(configFromEnv());
try {
const result = await cots.guard(
{ targetSystem: 'Slack', actionType: 'send_slack_message', actionName: 'Post deployment notice' },
async () => sendToSlackForReal('#ops', 'Deployment succeeded'),
);
console.log(result.decision, result.outcome);
// decision: 'allowed' | 'blocked' | 'require_approval'
// outcome: 'executed' | 'blocked' | 'timeout'
} catch (e) {
// network/HTTP failure calling the interceptor itself -- distinct from a
// policy decision (blocked/require_approval), which guard() returns, not throws
console.error(e instanceof Error ? e.message : String(e));
}A missing COTS_* var throws from configFromEnv(); a present-but-malformed one (swapped
key/secret, wrong prefix, a garbled URL) throws from createCotsClient() itself — both fail fast,
before any network call, instead of surfacing as a confusing 401 later. createAgentClient still
works too (same client, createCotsClient is just the name we'd recommend going forward).
Wire format
Every intercept() call (including the ones guard() makes for you) sends this agent's identity
as headers, not in the request body:
POST /v1/intercept
AgentID: <agentId>
TenantID: <tenantId>
APIKEY: <apiKey>
SECRET: <apiSecret>
Content-Type: application/json
{ "target_system": "Slack", "action_type": "send_slack_message", ... }Configuration
| Option | Required | Default | Description |
|---|---|---|---|
| tenantId | yes | — | Your organization's ID |
| agentId | yes | — | This agent's ID |
| apiKey | yes | — | This agent's own API key (from generateCredentials(), see Onboarding below) |
| apiSecret | yes | — | This agent's own API secret (same source as apiKey) |
| actionGatewayApiUrl | no | http://localhost:8083/api | The one base URL every SDK call goes through — intercept(), approval-status polling, and ping() all hit this; it forwards /v1/intercept to the Rust interceptor and /heartbeat to agent-heartbeat internally |
| approvalTimeoutMs | no | 30000 | How long guard()/waitForApproval() waits on a held action before giving up |
| approvalPollMs | no | 2000 | Poll interval while waiting on a held action's decision |
The localhost defaults are for local development only — set every URL to your actual deployed
endpoints everywhere else.
Reads COTS_TENANT_ID, COTS_AGENT_ID, COTS_API_KEY, COTS_API_SECRET,
COTS_ACTION_GATEWAY_API_URL, COTS_APPROVAL_TIMEOUT_MS, COTS_APPROVAL_POLL_MS.
COTS_TENANT_ID/COTS_AGENT_ID/COTS_API_KEY/COTS_API_SECRET are
required; configFromEnv() throws if any of them is missing. That's the whole config a deployed
agent needs — no login, no other service URLs.
Validation
Two layers, so a bad config never gets as far as a real network call:
- Missing —
configFromEnv()throws immediately if any of the four requiredCOTS_*vars isn't set. - Malformed —
createCotsClient()/createAgentClient()throws if a present value doesn't look right:tenantIdnot shaped liketen_...,agentIdnot shaped likeagt_...,apiKey/apiSecretnot shaped like..._key_<hex>/..._secret_<hex>(catches e.g. pasting the secret into the key field), oractionGatewayApiUrlnot a valid URL. This runs whether the config came fromconfigFromEnv()or a plain object literal you built yourself.
guard() — the one function most agents need
const result = await cots.guard(
{
targetSystem: 'Slack',
actionType: 'send_slack_message',
actionName: 'Post deployment notice',
riskScore: 15,
},
async () => sendToSlackForReal('#ops', 'Deployment succeeded'),
);guard() calls the interceptor first. If allowed, your callback runs immediately. If blocked, it
never runs. If require_approval, the call holds and polls for the human's decision until one comes
in (or approvalTimeoutMs elapses), then runs your callback only if approved.
NormalizedAction fields
| Field | Required | Description |
|---|---|---|
| targetSystem | yes | The system the action touches (e.g. "Slack", "Email", "Wallet Ledger") |
| actionType | yes | Machine-readable action identifier (e.g. "send_slack_message", "deduct_money") |
| actionName | no | Human-readable label shown in the audit trail |
| principalId | no | The human/service identity the agent is acting on behalf of |
| sessionId | no | Groups related actions under one session/conversation |
| dataClassification | no | e.g. "pii", "confidential", "financial" — informs policy decisions |
| riskScore | no | 0-100, informs risk tiering |
| amount | no | For financial actions — policy thresholds evaluate against this |
| recipient | no | Email address, phone number, etc. — used by recipient-domain policy rules |
| prompt | no | The agent's actual instruction/content, when available — scanned server-side for prompt-injection patterns |
Lower-level API
guard() composes two calls you can also use directly if you want to handle the decision yourself:
const result = await cots.intercept(action); // just the decision, no execution
const outcome = await cots.waitForApproval(actionEventId); // 'approved' | 'denied' | 'timeout'ping() / startHeartbeat() — liveness
createCotsClient()/createAgentClient() already start pinging in the background the moment you
construct the client — true minimal setup:
const cots = createCotsClient(configFromEnv());
// that's it -- "Last seen" on the Agents dashboard is now kept fresh automatically,
// every 60s, for as long as this process runs.This is controlled by config, not a second method call (so you don't have to choose between "automatic" and "customized" — just configure it):
| Option | Default | Description |
|---|---|---|
| autoPing | true | Set false to opt out entirely (e.g. to call startHeartbeat() yourself with a custom onSuccess/onError) |
| autoPingIntervalMs | 60000 | Interval for the automatic ping |
| autoPingInfo | — | host/region/runtime_version sent with each automatic ping |
COTS_AUTO_PING=false / COTS_AUTO_PING_INTERVAL_MS do the same via configFromEnv().
ping() itself authenticates the same way as intercept() — no login, just this agent's own
credentials — and confirms them by returning an explicit pong:
const pong = await cots.ping({ host: 'ip-10-0-0-4', region: 'us-east-1', runtime_version: '1.2.3' });
console.log(pong);
// { pong: true, agent_id: '...', tenant_id: '...', status: 'online', last_heartbeat_at: '...' }host/region/runtime_version are all optional. Each successful ping() also (best-effort)
refreshes the agent's last_seen_at shown on the Agents dashboard.
If you disabled autoPing (or want different timing/callbacks than the config gives you),
startHeartbeat() does the periodic part for you — pings once immediately, then every
intervalMs — instead of writing your own setInterval:
const cots = createCotsClient({ ...configFromEnv(), autoPing: false });
cots.startHeartbeat({
info: { runtime_version: '1.2.3' },
onSuccess: (pong) => console.log('[ping]', pong),
onError: (e) => console.error('[ping] failed', e),
});
// later, if you ever need to:
cots.stopHeartbeat();All fields are optional (defaults: no info, 60s interval, errors silently ignored). Calling
startHeartbeat() again while one is already running is a no-op — this includes the automatic one
autoPing starts, so pass your customization through autoPingIntervalMs/autoPingInfo (or set
autoPing: false and call startHeartbeat() yourself) rather than expecting a second call to
override it. The interval is unref()'d, so it won't by itself keep an otherwise-idle process
(e.g. a short script) alive.
Onboarding
Registering an organization and an agent is a separate, one-time setup step — usually done through
your Cots.ai dashboard, but also scriptable via the standalone ControlPlaneClient (unrelated to
AgentClient/guard() — a deployed agent never touches this, it only ever gets handed the
resulting tenantId/agentId/apiKey/apiSecret). onboardAgent() composes the whole sequence —
register tenant, log in, register agent, mint credentials, grant an action surface, activate — into
one call:
import { ControlPlaneClient } from '@cots/sample';
const controlPlane = new ControlPlaneClient({
identityTenantUrl: 'http://localhost:8081/api',
agentRegistryUrl: 'http://localhost:8082/api',
actionGatewayApiUrl: 'http://localhost:8083/api',
humanApprovalUrl: 'http://localhost:8089/api',
});
const result = await controlPlane.onboardAgent({
tenantName: 'Acme Corp',
adminEmail: '[email protected]',
adminPassword: 'a real password',
agentName: 'Ops Agent',
targetSystem: 'Slack',
allowedActionTypes: ['send_slack_message'],
});
// Store result.apiKey/result.apiSecret; they are never shown again. These,
// plus result.tenantId/result.agentId, are the only four values the
// deployed agent itself needs (see Configuration above).Need more control over an individual step (e.g. onboarding a second agent onto an existing tenant,
or inspecting activateAgent()'s returned PepConfig)? onboardAgent() is just a composition of
registerTenant() → login() → registerAgent() → generateCredentials() → createActionSurface()
→ activateAgent() — call them individually instead.
waitForApproval() (which guard() calls internally) authenticates as the agent itself — same
AgentID/TenantID/APIKEY/SECRET headers as intercept() — so once the agent is configured
with its own credentials, require_approval actions resolve on their own with no login anywhere in
the running agent process. See examples/manual-test.ts for the full onboard-then-run flow.
What this SDK does not do
- It doesn't send real Slack/SMS/email/payments — your
execute()callback does that, however you like. - It doesn't cache or retry beyond the approval poll loop — the interceptor call itself is a single
request; if it fails,
guard()/intercept()throw. - It has no dependency on any particular agent framework (LangChain, MCP, etc.) — call
guard()from wherever your agent decides to take an action, regardless of what's driving it. - It has no dependency on any particular web framework — works the same inside Express, Fastify, a Next.js route handler, an AWS Lambda, or a plain CLI script.
License
Copyright © Cots.ai. All rights reserved.
