New:Microsoft Teams Notifications Are Now Available in Socket.Learn more
Get Started

@blacklake-systems/surface-sdk

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@blacklake-systems/surface-sdk

TypeScript SDK for BlackLake Surface — agent governance infrastructure

Source
npmnpm
Version
0.2.0
Version published
Weekly downloads
0
-100%
Maintainers
1
Weekly downloads
 
Created
Source

@blacklake-systems/surface-sdk

TypeScript SDK for BlackLake Surface — the control plane for your AI agents. Integrate governance directly into custom agent code, whether you use the hosted cloud product or run Surface locally.

Note: If you are routing tool calls through the MCP proxy, you do not need this SDK. The proxy handles governance automatically. Use the SDK when you want to call the governance API directly from your own code.

Install

npm install @blacklake-systems/surface-sdk

Quick Start

Sign up at console.blacklake.systems and grab your API key from the dashboard. Then:

import { BlackLake } from '@blacklake-systems/surface-sdk';

const bl = new BlackLake({ apiKey: process.env.BLACKLAKE_API_KEY! });

const decision = await bl.govern({
  agent: 'my-bot',
  tool: 'send_email',
  action: { to: 'alice@example.com' },
});

switch (decision.decision) {
  case 'allow':
    // safe to proceed
    break;
  case 'approval_required':
    // wait for a human reviewer; decision.approval_id has the pending approval
    break;
  case 'deny':
  case 'default_deny':
    // not allowed. decision.reason explains why.
    throw new Error(`BlackLake denied: ${decision.reason}`);
}

default_deny is the fail-safe — it means no policy matched. Treat it the same as deny in your code; if you see it for a call you expected to allow, write a policy that matches the agent + tool selectors.

baseUrl defaults to https://api.blacklake.systems. No further configuration needed for the cloud product.

Self-hosted

Run npx @blacklake-systems/surface-cli first to start Surface on your machine, then point the SDK at it:

import { BlackLake } from '@blacklake-systems/surface-sdk';

const bl = new BlackLake({
  baseUrl: 'http://localhost:3100',
  apiKey: process.env.BLACKLAKE_API_KEY!,
});

// Evaluate governance before executing a tool call
const result = await bl.govern({
  agent: 'expense-bot',
  tool: 'payments.send',
  action: { amount: 4200, vendor: 'Acme Corp' },
});

if (result.decision === 'allow') {
  // proceed with tool call
}

Or use the hosted version at console.blacklake.systems

Pairs with BlackLake Depth — the durable-execution runtime that survives crashes. Use Depth to run multi-step agent workflows; Surface evaluates each tool call inside them.

API Reference

new BlackLake(config)

OptionTypeDefaultDescription
apiKeystringYour BlackLake API key (required)
baseUrlstringhttps://api.blacklake.systemsAPI base URL. Override only for local development (e.g. http://localhost:3100).

bl.govern(request)

Evaluate whether an agent is allowed to invoke a tool.

const result = await bl.govern({
  agent: 'expense-bot',       // agent name
  tool: 'payments.send',      // tool name
  action: { amount: 4200 },   // optional: tool invocation payload
  context: { ip: '10.0.0.1' } // optional: request metadata
});

// result.decision: 'allow' | 'deny' | 'approval_required' | 'default_deny'
// result.evaluation_id: string
// result.policy_id: string | null
// result.reason: string
// result.evaluated_at: string (ISO 8601)
// result.approval_id: string | undefined (set when decision === 'approval_required')
// result.decision_token: string — HMAC-signed receipt binding (evaluation_id, decision).
//   Quote this when reporting a governance outcome to a downstream operator — they can
//   confirm via bl.decisions.verify(...) that the decision really came from this server,
//   not from an LLM hallucinating a denial.

Handle every decision explicitly. default_deny is returned when no policy matches and the agent has no binding for the tool — it is distinct from deny (an explicit deny policy matched). Treating it as a generic fallback is a footgun:

switch (result.decision) {
  case 'allow':              return await payments.send(payload);
  case 'deny':               throw new Error(`blocked: ${result.reason}`);
  case 'approval_required':  return awaitApproval(result.approval_id!);
  case 'default_deny':       throw new Error(
    `no matching policy or binding — register '${tool}' for '${agent}' or add an allow policy`,
  );
}

bl.agents

await bl.agents.create({ name, environment, risk_classification, description?, approval_mode? });
await bl.agents.list({ environment?, status? });
await bl.agents.get(id);
await bl.agents.update(id, { name?, description?, environment?, risk_classification?, status?, approval_mode? });
await bl.agents.suspend(id);
await bl.agents.activate(id);
await bl.agents.bindTool(agentId, toolId);
await bl.agents.listTools(agentId);   // returns ToolBinding[] — each item has { binding_id, binding_created_at, tool: Tool }
await bl.agents.unbindTool(agentId, toolId);

bl.tools

await bl.tools.create({ name, risk_classification, description? });
await bl.tools.list();
await bl.tools.get(id);

bl.policies

await bl.policies.create({ name, priority, outcome, agent_selector?, tool_selector?, enabled? });
await bl.policies.list();
await bl.policies.get(id);
await bl.policies.update(id, { name?, priority?, outcome?, agent_selector?, tool_selector?, enabled? });
await bl.policies.delete(id);

bl.evaluations

await bl.evaluations.list({ agent_id?, tool_id?, outcome?, limit?, offset? });
await bl.evaluations.get(id);

Verifying decisions

LLM agents can fabricate text that looks like a denial — 'BlackLake denied this tool call' — without ever actually invoking the bridge. Decision tokens close that gap. Every honest govern() call returns an HMAC-signed token bound to (evaluation_id, decision); a hallucinated token fails verification. Use bl.decisions.verify(...) whenever you're acting on a governance outcome reported by an agent rather than the API directly.

import { BlackLake } from '@blacklake-systems/surface-sdk';
const bl = new BlackLake({ apiKey: process.env.BLACKLAKE_API_KEY! });

const decision = await bl.govern({
  agent: 'my-bot',
  tool: 'send_email',
  action: { to: 'alice@example.com' },
});

// Later (e.g. in an operator's audit tool, or a different process):
const verification = await bl.decisions.verify({
  evaluation_id: decision.evaluation_id,
  decision_token: decision.decision_token,
});

if (verification.valid) {
  console.log('Confirmed: this was a real BlackLake decision', verification.decision);
} else {
  console.warn('Token did not verify:', verification.reason);
}

bl.organisation

await bl.organisation.get();                  // fetch the current organisation (derived from the API key)
await bl.organisation.delete(confirmation);   // permanently delete the organisation; pass the organisation's exact name as confirmation

bl.apiKeys

await bl.apiKeys.list();              // returns { keys: ApiKey[] } — each item has { id, name, key_suffix, created_at, revoked_at }
await bl.apiKeys.create('prod-key');  // returns { id, name, key, created_at, warning } — the raw key is shown ONCE; store it securely
await bl.apiKeys.revoke(id);          // sets revoked_at on the key; the API rejects revoking the key in use

bl.approvals

await bl.approvals.list({ status?, agent_id?, tool_id?, limit?, offset? });  // returns PaginatedResponse<Approval>
await bl.approvals.get(id);
await bl.approvals.status(id);                                               // returns ApprovalStatusResponse — lightweight poll target
await bl.approvals.approve(id, { decided_by, reason });
await bl.approvals.reject(id, { decided_by, reason });
await bl.approvals.wait(id, { interval?, timeout? });                        // polls status until approved/rejected/expired; throws BlackLakeError on timeout

wait() defaults to polling every 2 000 ms with a 5-minute total timeout, then throws BlackLakeError with code APPROVAL_WAIT_TIMEOUT and HTTP status 408. These defaults are sensible for an interactive approval queue; for high-frequency agents set a shorter timeout (ms) and handle the throw.

try {
  const resolved = await bl.approvals.wait(result.approval_id!, { timeout: 30_000 });
  if (resolved.status === 'approved') { /* proceed */ }
  else                                 { /* rejected or expired — do NOT proceed */ }
} catch (err) {
  if (err instanceof BlackLakeError && err.code === 'APPROVAL_WAIT_TIMEOUT') {
    // queue for later, page a human, or reject the original request
  }
  else throw err;
}

Returns the fully-populated Approval once the status leaves 'pending'. Always branch on resolved.statuswait() does not throw for rejected or expired; the caller must inspect the resolved record.

bl.webhooks

await bl.webhooks.list();                                          // returns { webhooks: Webhook[] }
await bl.webhooks.create({ url, events, enabled? });              // returns CreatedWebhook — the raw signing secret is shown ONCE; store it securely
await bl.webhooks.get(id);
await bl.webhooks.update(id, { url?, events?, enabled? });
await bl.webhooks.delete(id);
await bl.webhooks.listDeliveries(id, { limit?, offset? });        // returns PaginatedResponse<WebhookDelivery>

Webhooks fire on 'approval.created', 'approval.approved', and 'approval.rejected'. Each request is signed with HMAC-SHA256 over "<timestamp>.<raw_body>"; the signature is sent in the X-BlackLake-Signature header (format: sha256=<hex>) and the millisecond timestamp in X-BlackLake-Timestamp.

Verifying webhook signatures

Always verify the signature before trusting a webhook payload. The SDK ships a constant-time helper that uses the Web Crypto API (no Node crypto dependency, so it works in Cloudflare Workers, Deno, and browsers):

import { BlackLake, BlackLakeError } from '@blacklake-systems/surface-sdk';

// In your webhook handler (Express example):
app.post('/webhooks/blacklake', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    await BlackLake.verifyWebhookSignature({
      secret: process.env.BLACKLAKE_WEBHOOK_SECRET!,
      rawBody: req.body.toString('utf8'),          // the raw bytes, not JSON.stringify(req.body)
      signature: req.header('x-blacklake-signature')!,
      timestamp: req.header('x-blacklake-timestamp')!,
    });
  } catch (err) {
    if (err instanceof BlackLakeError && err.code === 'WEBHOOK_SIGNATURE_INVALID') {
      return res.status(401).end();
    }
    throw err;
  }
  // signature verified — safe to parse the body and act on it
  const event = JSON.parse(req.body.toString('utf8'));
  res.status(204).end();
});

Rejects on length mismatch, wrong prefix, or signature mismatch. Constant-time comparison is used to avoid timing side-channels.

bl.system

await bl.system.mode();    // { mode: 'local' | 'cloud', api_key?: string }  — unauthenticated
await bl.system.health();  // { status: 'ok' }                                — unauthenticated

Use bl.system.mode() to detect whether you're talking to a local CLI-hosted Surface or the cloud one — useful when the same SDK-driven tool needs to work in both environments.

bl.mcp

await bl.mcp.list();                 // { servers: McpServerStatus[], config_path: string }
await bl.mcp.reconnect(serverName);  // { connected: boolean, tools: number, error?: string }

Manage MCP upstream servers programmatically (status, forced reconnect). Same endpoints the console MCP Servers page uses.

Error Handling

import { BlackLake, BlackLakeError } from '@blacklake-systems/surface-sdk';

try {
  await bl.govern({ agent: 'unknown', tool: 'unknown' });
} catch (err) {
  if (err instanceof BlackLakeError) {
    console.error(err.status, err.code, err.message);
    if (err.isRetriable()) {
      // 5xx / 408 / 429 — safe to back off and retry
    }
  }
}

BlackLakeError.isRetriable() returns true for HTTP 5xx, 408 Request Timeout, and 429 Too Many Requests. 4xx client errors are not retriable — fix the request instead.

Documentation

Full documentation at blacklake.systems/docs.

Keywords

blacklake

FAQs

Package last updated on 29 Apr 2026

Related posts