@blacklake-systems/surface-sdk
⚠️ Deprecated. This package is now part of the unified blacklake npm package. Install blacklake and import { govern } from 'blacklake'. See the migration doc for sed-style search-and-replace examples. This package will continue to ship as a thin re-export through the next two minor versions.
TypeScript SDK for BlackLake — AI control infrastructure and analytics.
Use this SDK when your code calls LLMs or tools directly (backend services, custom agents, batch jobs) and you want every consequential action on the same ledger as MCP proxy, CI, shell, cloud audit ingest, and Depth workflows. bl.govern() returns the decision, bl.cost.record() attributes spend, bl.decisions.verify() proves a receipt later.
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':
break;
case 'approval_required':
break;
case 'deny':
case 'default_deny':
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.
Local Surface
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!,
});
const result = await bl.govern({
agent: 'expense-bot',
tool: 'payments.send',
action: { amount: 4200, vendor: 'Acme Corp' },
});
if (result.decision === 'allow') {
}
Or use the hosted Surface console at console.blacklake.systems when you need shared policies, approvals, budgets, exports, and team visibility.
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)
apiKey | string | — | Your BlackLake API key (required) |
baseUrl | string | https://api.blacklake.systems | API 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',
tool: 'payments.send',
action: { amount: 4200 },
context: { ip: '10.0.0.1' }
});
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);
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' },
});
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();
await bl.organisation.delete(confirmation);
bl.apiKeys
await bl.apiKeys.list();
await bl.apiKeys.create('prod-key');
await bl.apiKeys.revoke(id);
bl.approvals
await bl.approvals.list({ status?, agent_id?, tool_id?, limit?, offset? });
await bl.approvals.get(id);
await bl.approvals.status(id);
await bl.approvals.approve(id, { decided_by, reason });
await bl.approvals.reject(id, { decided_by, reason });
await bl.approvals.wait(id, { interval?, 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') { }
else { }
} catch (err) {
if (err instanceof BlackLakeError && err.code === 'APPROVAL_WAIT_TIMEOUT') {
}
else throw err;
}
Returns the fully-populated Approval once the status leaves 'pending'.
Always branch on resolved.status — wait() does not throw for rejected
or expired; the caller must inspect the resolved record.
bl.webhooks
await bl.webhooks.list();
await bl.webhooks.create({ url, events, enabled? });
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? });
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';
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'),
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;
}
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.audit
Export the audit ledger, ingest external events, and inspect coverage gaps.
const ndjson = await bl.audit.export({
from: new Date(Date.now() - 30 * 86400_000),
to: new Date(),
kinds: ['evaluation', 'approval', 'action_result'],
});
const fullNdjson = await bl.audit.export({
from: new Date(Date.now() - 200 * 86400_000),
to: new Date(),
kinds: ['evaluation'],
includeArchived: true,
});
const event = await bl.audit.ingest({
source: 'github',
source_event_id: 'run-123',
event_type: 'workflow_run',
resource: 'my-org/my-repo',
occurred_at: new Date().toISOString(),
payload: { conclusion: 'success' },
});
await bl.audit.listEvents({ source: 'github', limit: 50 });
await bl.audit.listUncovered({ limit: 25 });
Each line of the exported NDJSON has shape { type: 'evaluation' | 'approval' | 'action_result', data: { ... } }. The window is capped at 365 days server-side; for longer ranges call repeatedly with non-overlapping windows.
bl.system
await bl.system.mode();
await bl.system.health();
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();
await bl.mcp.reconnect(serverName);
const result = await bl.mcp.rotate(upstreamId, { headers: { Authorization: 'Bearer new-key' } });
const result = await bl.mcp.rotate(upstreamId);
Manage MCP upstream servers programmatically (status, forced reconnect, credential rotation). Same
endpoints the console MCP Servers page uses.
bl.mcp.rotate() keeps the upstream row and all its agent/tool/policy bindings intact — it is the correct way to swap an API key that changed, or to force a user through OAuth consent again without deleting and recreating the upstream. For static_headers upstreams, include { headers: { ... } } in the options; for oauth2 upstreams the options argument is ignored and the response contains an authorization_url the user must visit. OAuth rotation requires a session-authenticated caller — org-scoped API keys will receive a 401 USER_AUTH_REQUIRED.
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()) {
}
}
}
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.