Skip to content
BLACKLAKE
SDK reference▾ docs nav

SDK Reference

Complete reference for the SDK exports of the blacklake package. The same package ships the CLI (blacklake, blx) and the durable-workflow runtime.

For MCP-compatible tools the proxy handles governance automatically — you don't need the SDK. Reach for it when you're building your own agent runtime and want bl.govern() in your own code path.

npm install blacklake

New in 0.3.0: cost-governance namespaces are now reachable — bl.cost, bl.budgets, and bl.insights were defined in 0.2.0 source but not exported, so the docs advertised methods that threw TypeError at runtime. Also: GovernRequest.estimate (required for cost-aware policies to fire before the spend), session_id / task_id / user_id for scope attribution, and GovernResponse.estimated_cost_usd + budget_headroom_usd so operators can see what the cost gate decided. See the SDK CHANGELOG.


Constructor#

import { BlackLake } from 'blacklake';

const bl = new BlackLake(config: BlackLakeConfig);

BlackLakeConfig#

interface BlackLakeConfig {
  apiKey: string;     // Required. Your organisation's API key.
  baseUrl?: string;   // Optional. Defaults to https://api.blacklake.systems.
                      // Set to 'http://localhost:3100' for local mode.
}

Local mode: when running npx blacklake serve, the API is available at http://localhost:3100. No API key is required in local mode — you can pass any non-empty string.

// Local mode
const bl = new BlackLake({
  apiKey: 'local',
  baseUrl: 'http://localhost:3100',
});

// Cloud mode
const bl = new BlackLake({
  apiKey: process.env.BLACKLAKE_API_KEY!,
  // baseUrl defaults to https://api.blacklake.systems
});

All methods on the SDK instance share a single HTTP client configured with the apiKey and baseUrl provided here.


bl.govern()#

Evaluate whether an agent is permitted to invoke a tool. Call this before every tool execution.

bl.govern(request: GovernRequest): Promise<GovernResponse>

GovernRequest#

interface GovernRequest {
  agent: string;                        // Agent name (as registered)
  tool: string;                         // Tool name (as registered)
  action?: Record<string, unknown>;     // Optional. The action the agent wants to take. Logged with the evaluation.
  context?: Record<string, unknown>;    // Optional. Caller-supplied context. Logged with the evaluation.
  /**
   * Pre-call cost estimate. REQUIRED for cost-aware policies and budget
   * pre-checks to deny before the LLM call leaves the network.
   * Without it, the resolver sees `tool.estimated_cost_usd = 0` and
   * cost-aware gt/gte conditions never match.
   */
  estimate?: GovernEstimate;
  session_id?: string;  // Group of related calls within an agent run.
  task_id?: string;     // User-facing task within a session.
  user_id?: string;     // Caller-supplied user attribution; drives per-user budgets.
}

interface GovernEstimate {
  provider: string;
  model: string;
  input_tokens: number;
  output_ceiling_tokens: number;
  estimated_cost_usd: number;  // Run bl.cost.estimate() first; pass total_usd here.
}

GovernResponse#

interface GovernResponse {
  decision: 'allow' | 'deny' | 'approval_required' | 'default_deny';
  evaluation_id: string;      // ID of the recorded evaluation.
  policy_id: string | null;   // ID of the matched policy, or null.
  reason: string;             // Human-readable explanation of the decision.
  evaluated_at: string;       // ISO 8601 UTC timestamp.
  approval_id?: string;       // Present only when decision === 'approval_required'.
  decision_token: string;     // Signed receipt token. Verify with bl.decisions.verify().
  estimated_cost_usd?: number;       // Echo of the supplied estimate. Confirms the server saw what you sent.
  budget_headroom_usd?: number | null; // Headroom on the tightest enforcing budget. null when no enforcing budget applies.
}

Example#

const result = await bl.govern({
  agent: 'data-pipeline-agent',
  tool: 'write-to-database',
  action: { table: 'orders', operation: 'insert' },
  context: { run_id: 'run_abc123' },
});

if (result.decision === 'allow') {
  await writeToDatabase(payload);
} else {
  throw new Error(`Blocked by governance: ${result.reason} (${result.evaluation_id})`);
}

Surface-only idioms — withGovernance, governedTool, detectEngine#

When you already have an engine — Temporal, Inngest, Trigger.dev, BullMQ, GitHub Actions, or plain HTTP — and want BlackLake to gate the consequential steps without inverting your architecture, use the helpers shipped from the blacklake package.

withGovernance()#

import { BlackLake, withGovernance } from 'blacklake';

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

const result = await withGovernance(
  bl,
  {
    agent: 'support-bot',
    tool: 'stripe.refund',
    action: { amount_cents: 4200 },
    externalSystem: 'temporal',
    context: {
      engine: { engine: 'temporal', workflow_id: 'wf_1', run_id: 'r_2', step_id: 'refund', attempt: 1 },
    },
  },
  async () => stripe.refunds.create({ payment_intent: 'pi_3Nq8X', amount: 4200 }),
);

govern() is called first. On allow the inner function runs and an action_result (succeeded / failed with duration_ms and any error message) is posted to the receipt automatically — pass recordActionResult: false to opt out. On deny / default_deny it throws GovernDeniedError without executing. On approval_required it throws GovernApprovalRequiredError by default; pass { onApproval: 'wait' } to block on bl.approvals.wait(...) and run iff approved.

governedTool()#

Curry the agent + tool bindings when the same wrapper is invoked from many call sites:

import { governedTool } from 'blacklake';

const refund = governedTool(
  bl,
  { agent: 'support-bot', tool: 'stripe.refund', externalSystem: 'inngest' },
  async (paymentIntent: string, amount: number) =>
    stripe.refunds.create({ payment_intent: paymentIntent, amount }),
);

await refund(
  { action: { amount: 4200 }, context: { engine: { engine: 'inngest', run_id: 'r' } } },
  'pi_3Nq8X',
  4200,
);

Engine context block#

govern({ context: { engine } }) is the standardized way to attribute a decision to the workflow / run / step that originated it. The well-known fields:

FieldMeaning
engine'temporal' / 'inngest' / 'trigger' / 'bullmq' / 'github-actions' / 'gitlab-ci' / 'buildkite' / 'circleci' / 'jenkins' / 'cloud-run-job' / 'lambda' / 'cron' / 'depth' / free-form
workflow_idEngine's workflow / pipeline / job-name id
run_idEngine's run / build / execution id
step_idEngine's step / activity / job id
attemptRetry attempt number (engine-specific semantics)
queueTask queue / pool the run executes on
repoSource repo (org/repo or full URL)
commit_shaCommit hash that triggered the run
environment'development' / 'staging' / 'production'
ticket_idLinked ticket id (e.g. T-4821)

detectEngine()#

Best-effort env-based detection for CI engines (GitHub Actions, GitLab CI, Buildkite, CircleCI, Jenkins, Cloud Run Jobs):

import { detectEngine } from 'blacklake';

const engine = detectEngine();
// { engine: 'github-actions', workflow_id: 'release', run_id: '12345', ... }
// — or {} when no CI env is detected.

await bl.govern({
  agent: 'release-bot',
  tool: 'gcloud.run.deploy',
  context: { engine },
});

Workflow engines that don't expose their context via env vars (Temporal, Inngest, Trigger.dev, BullMQ) should populate the block explicitly from inside the workflow handler — see the runnable examples in packages/blacklake/examples/.


bl.agents#

bl.agents.create()#

Register a new agent.

bl.agents.create(data: {
  name: string;
  description?: string;
  environment: 'development' | 'staging' | 'production';
  risk_classification: 'low' | 'medium' | 'high' | 'critical';
  approval_mode?: 'auto_approve' | 'require_approval' | 'block';
  /**
   * Free-text identifier (email, team name, ticket reference) for audit
   * attribution. Strongly recommended in production.
   */
  owner?: string;
  /**
   * Capture path that registered this agent. Defaults to 'manual' for SDK
   * callers; set to 'sdk' to attribute the agent to your application code,
   * 'ci' for governed CI pipelines, etc. Used by the Coverage view.
   */
  source?: 'manual' | 'mcp' | 'sdk' | 'ci';
}): Promise<Agent>
const agent = await bl.agents.create({
  name: 'fraud-detection-agent',
  environment: 'production',
  risk_classification: 'high',
  owner: 'platform-team@acme.example',
  source: 'sdk',
});

bl.agents.list()#

List agents in the organisation, with optional filters. Returns the standard list envelope — agents are in .data. Soft-deleted agents are hidden by default; pass include_deleted: true to render an archive view.

bl.agents.list(params?: {
  environment?: 'development' | 'staging' | 'production';
  status?: 'active' | 'suspended' | 'disabled';
  limit?: number;
  offset?: number;
  sort?: 'created_at' | 'updated_at' | 'name' | 'environment' | 'risk_classification' | 'status';
  order?: 'asc' | 'desc';
  include_deleted?: boolean;
  only_deleted?: boolean;
}): Promise<{
  data: Agent[];
  total: number;
  limit: number;
  offset: number;
  sort: string;
  order: 'asc' | 'desc';
}>
// All active production agents
const { data: agents, total } = await bl.agents.list({
  environment: 'production',
  status: 'active',
});

bl.agents.get()#

Retrieve a single agent by ID.

bl.agents.get(id: string): Promise<Agent>
const agent = await bl.agents.get('agent_01j...');

bl.agents.update()#

Update agent fields. Only provided fields are changed.

bl.agents.update(
  id: string,
  data: Partial<{
    name: string;
    description: string;
    environment: 'development' | 'staging' | 'production';
    risk_classification: 'low' | 'medium' | 'high' | 'critical';
    status: 'active' | 'suspended' | 'disabled';
    approval_mode: 'auto_approve' | 'require_approval' | 'block';
    owner: string;
  }>
): Promise<Agent>
const updated = await bl.agents.update('agent_01j...', {
  risk_classification: 'critical',
});

bl.agents.suspend()#

Set agent status to suspended. Governance calls for this agent will return deny.

bl.agents.suspend(id: string): Promise<Agent>
await bl.agents.suspend('agent_01j...');

bl.agents.activate()#

Set agent status back to active.

bl.agents.activate(id: string): Promise<Agent>
await bl.agents.activate('agent_01j...');

bl.agents.delete()#

Soft-delete (archive) an agent. The record stays in the database — bindings and the audit trail are preserved — but bl.govern() will no longer resolve it by name and bl.agents.list() hides it by default. The freed name can immediately be reused for a brand new agent. Reversible via bl.agents.restore(id). Idempotent: re-deleting an archived record returns the same row.

bl.agents.delete(id: string): Promise<Agent>
const archived = await bl.agents.delete('agent_01j...');
console.log(archived.deleted_at); // ISO timestamp

bl.agents.restore()#

Un-archive a soft-deleted agent. Fails with 409 AGENT_NAME_CONFLICT if another active agent has since claimed the name; rename or delete that agent first, then retry.

bl.agents.restore(id: string): Promise<Agent>
await bl.agents.restore('agent_01j...');

bl.agents.bindTool()#

Create a binding between an agent and a tool, granting the agent access to be governed against that tool.

bl.agents.bindTool(
  agentId: string,
  toolId: string
): Promise<{ id: string; agent_id: string; tool_id: string; created_at: string }>
await bl.agents.bindTool('agent_01j...', 'tool_01j...');

bl.agents.listTools()#

List tools bound to an agent. Each item includes the binding metadata and the full tool object.

The wire format is the standard list envelope ({ data, total, limit, offset, sort, order }); the SDK unwraps it for you and returns the array directly.

bl.agents.listTools(agentId: string): Promise<ToolBinding[]>

interface ToolBinding {
  binding_id: string;
  binding_created_at: string;
  tool: Tool;
}
const bindings = await bl.agents.listTools('agent_01j...');
for (const b of bindings) {
  console.log(b.tool.name, b.binding_created_at);
}

bl.agents.unbindTool()#

Remove a binding between an agent and a tool. Returns void. After this call, governance requests from this agent for this tool will be denied.

This call is idempotent: deleting a binding that does not exist still returns 204 No Content, so cleanup scripts can run safely without first checking whether the binding is present.

bl.agents.unbindTool(agentId: string, toolId: string): Promise<void>
await bl.agents.unbindTool('agent_01j...', 'tool_01j...');

bl.tools#

bl.tools.create()#

Register a new tool.

bl.tools.create(data: {
  name: string;
  description?: string;
  risk_classification: 'low' | 'medium' | 'high' | 'critical';
  /** Free-text owner — same shape as on agents. */
  owner?: string;
  /** Capture path that registered this tool. Used by Coverage. */
  source?: 'manual' | 'mcp' | 'sdk' | 'ci';
}): Promise<Tool>
const tool = await bl.tools.create({
  name: 'delete-record',
  description: 'Permanently deletes a database record',
  risk_classification: 'critical',
  owner: 'platform-team@acme.example',
  source: 'sdk',
});

bl.tools.list()#

List tools in the organisation. Returns the standard list envelope — tools are in .data. Soft-deleted tools are hidden by default; pass include_deleted: true to render an archive view.

bl.tools.list(params?: {
  limit?: number;
  offset?: number;
  sort?: 'created_at' | 'name' | 'risk_classification';
  order?: 'asc' | 'desc';
  include_deleted?: boolean;
  only_deleted?: boolean;
}): Promise<{
  data: Tool[];
  total: number;
  limit: number;
  offset: number;
  sort: string;
  order: 'asc' | 'desc';
}>
const { data: tools } = await bl.tools.list();

bl.tools.get()#

Retrieve a single tool by ID.

bl.tools.get(id: string): Promise<Tool>
const tool = await bl.tools.get('tool_01j...');

bl.tools.update()#

Patch tool inventory metadata. Only owner, name, description, and risk_classification can be updated; everything else (e.g. source, last_seen_at) is system-owned.

bl.tools.update(id: string, patch: { name?, description?, risk_classification?, owner? }): Promise<Tool>
const tool = await bl.tools.update('tool_01j...', { owner: 'platform-team@acme.example' });

bl.tools.delete()#

Soft-delete (archive) a tool. The record stays in the database — bindings and the audit trail are preserved — but bl.govern() treats the tool as not-bound and bl.tools.list() hides it by default. Reversible via bl.tools.restore(id). Idempotent.

bl.tools.delete(id: string): Promise<Tool>
await bl.tools.delete('tool_01j...');

bl.tools.restore()#

Un-archive a soft-deleted tool.

bl.tools.restore(id: string): Promise<Tool>
await bl.tools.restore('tool_01j...');

bl.policies#

bl.policies.create()#

Create a new policy.

bl.policies.create(data: {
  name: string;
  priority: number;
  agent_selector?: Record<string, unknown>;
  tool_selector?: Record<string, unknown>;
  outcome: 'allow' | 'deny' | 'approval_required';
  enabled?: boolean;
  requires_two_person?: boolean;
  approver_roles?: string[];
  cost_conditions?: Record<string, unknown> | null;
  mode?: 'enforce' | 'monitor';
}): Promise<Policy>
const policy = await bl.policies.create({
  name: 'block-high-risk-tools-in-prod',
  priority: 1,
  agent_selector: { environment: 'production' },
  tool_selector: { risk_classification: 'high' },
  outcome: 'deny',
});

bl.policies.list()#

List policies. Returns the standard list envelope — policies are in .data. Supports ?limit=, ?offset=, ?sort=name|priority|created_at|updated_at, and ?order=asc|desc. Default sort is priority asc so the highest-precedence policy is first.

bl.policies.list(params?: {
  limit?: number;
  offset?: number;
  sort?: 'name' | 'priority' | 'created_at' | 'updated_at';
  order?: 'asc' | 'desc';
}): Promise<{
  data: Policy[];
  total: number;
  limit: number;
  offset: number;
  sort: string;
  order: 'asc' | 'desc';
}>
const { data: policies, total } = await bl.policies.list();

bl.policies.get()#

Retrieve a single policy by ID.

bl.policies.get(id: string): Promise<Policy>
const policy = await bl.policies.get('pol_01j...');

bl.policies.update()#

Update policy fields. Only provided fields are changed. Updating a policy flushes the policy cache immediately.

bl.policies.update(
  id: string,
  data: Partial<{
    name: string;
    priority: number;
    agent_selector: Record<string, unknown>;
    tool_selector: Record<string, unknown>;
    outcome: 'allow' | 'deny' | 'approval_required';
    enabled: boolean;
    requires_two_person: boolean;
    approver_roles: string[] | null;
    cost_conditions: Record<string, unknown> | null;
    mode: 'enforce' | 'monitor';
  }>
): Promise<Policy>
// Disable a policy without deleting it
const updated = await bl.policies.update('pol_01j...', { enabled: false });

bl.policies.delete()#

Permanently delete a policy. Returns void. This flushes the policy cache immediately.

bl.policies.delete(id: string): Promise<void>

bl.policies.simulate()#

Replay historical evaluations against a draft policy and report the change in decision counts. Useful for measuring blast radius before enabling a new gate.

bl.policies.simulate(draft: {
  priority: number;
  agent_selector?: Record<string, unknown>;
  tool_selector?: Record<string, unknown>;
  outcome: 'allow' | 'deny' | 'approval_required';
  window_days?: number;  // default 30, max 365
  sample_limit?: number; // default 50, max 500
}): Promise<PolicySimulationResult>
const sim = await bl.policies.simulate({
  priority: 5,
  agent_selector: { environment: 'production' },
  tool_selector: { risk_classification: 'high' },
  outcome: 'approval_required',
  window_days: 30,
});

console.log(sim.before, sim.after, sim.changes.transitions);
// { allow: 1500, ... } { allow: 1450, approval_required: 150, ... }
// { allow_to_approval_required: 50 }
await bl.policies.delete('pol_01j...');

bl.evaluations#

bl.evaluations.list()#

List evaluation records with optional filters. Returns a paginated response.

bl.evaluations.list(params?: {
  agent_id?: string;
  tool_id?: string;
  outcome?: 'allow' | 'deny' | 'approval_required' | 'default_deny';
  limit?: number;   // Default: 50
  offset?: number;  // Default: 0
}): Promise<PaginatedResponse<Evaluation>>
// Last 10 denied evaluations for a specific agent
const { data, total } = await bl.evaluations.list({
  agent_id: 'agent_01j...',
  outcome: 'deny',
  limit: 10,
});

console.log(`Showing ${data.length} of ${total} denials`);

bl.evaluations.get()#

Retrieve a single evaluation by ID.

bl.evaluations.get(id: string): Promise<Evaluation>
const evaluation = await bl.evaluations.get('eval_01j...');

bl.evaluations.listResults()#

List execution or delivery result evidence attached to an evaluation.

bl.evaluations.listResults(id: string): Promise<{ data: ActionResult[] }>
const { data: results } = await bl.evaluations.listResults('eval_01j...');

for (const result of results) {
  console.log(result.status, result.external_system, result.external_id);
}

bl.evaluations.recordResult()#

Attach execution or delivery result evidence to an evaluation. Store compact evidence such as status, external run IDs, exit codes, digests, and metadata; avoid storing raw command output or secrets.

bl.evaluations.recordResult(
  id: string,
  body: CreateActionResultRequest,
): Promise<ActionResult>

CreateActionResultRequest

FieldTypeRequiredDescription
status'succeeded' | 'failed' | 'skipped' | 'unknown'YesOutcome of the executed action. Anything else is rejected with VALIDATION_ERROR.
external_systemstringNoSystem that ran or recorded the action — e.g. bash, github-actions, cloud-run, jira.
external_idstringNoDurable ID assigned by the external system. Reused as a reconciliation key when ingesting cloud audit events.
external_urlstringNoURL to the run, job, issue, log, or receipt in the external system.
duration_msintegerNoExecution duration in milliseconds.
exit_codeintegerNoProcess exit code when applicable.
output_digeststringNoDigest of output or artifact content — e.g. sha256:<hex>. Compact integrity proof without storing the artifact itself.
errorstringNoShort error message when the result failed.
metadataobjectNoIntegration-specific structured metadata. Stored verbatim; avoid putting secrets here.
const decision = await bl.govern({
  agent: 'release-agent',
  tool: 'deploy-service',
  action: { service: 'api', environment: 'production' },
  context: { run_id: 'run_123' },
});

if (decision.decision === 'allow') {
  const started = Date.now();

  try {
    const run = await deployService();

    await bl.evaluations.recordResult(decision.evaluation_id, {
      status: 'succeeded',
      external_system: 'github-actions',
      external_id: run.id,
      external_url: run.url,
      duration_ms: Date.now() - started,
      exit_code: 0,
      output_digest: run.outputDigest,
      metadata: { workflow: 'deploy' },
    });
  } catch (error) {
    await bl.evaluations.recordResult(decision.evaluation_id, {
      status: 'failed',
      external_system: 'github-actions',
      duration_ms: Date.now() - started,
      error: error instanceof Error ? error.message : String(error),
    });

    throw error;
  }
}

bl.decisions#

bl.decisions.verify()#

Verify that a (evaluation_id, decision_token) pair was issued by BlackLake. A valid receipt returns the decision, matched policy snapshot, and any attached result evidence.

bl.decisions.verify(params: {
  evaluation_id: string;
  decision_token: string;
}): Promise<VerifyDecisionResult>
const receipt = await bl.decisions.verify({
  evaluation_id: decision.evaluation_id,
  decision_token: decision.decision_token,
});

if (receipt.valid) {
  console.log(receipt.decision, receipt.policy_name, receipt.action_results);
} else {
  console.warn(`Receipt verification failed: ${receipt.reason}`);
}

bl.cost#

LLM cost capture and pre-call estimation. Records land in cost_records, roll up onto the bound evaluation's receipt, and bump the receipt to v2 so the dollar figure is cryptographically signed alongside the decision. See the Cost Governance concepts page for the surrounding model.

bl.cost.record()#

Attribute an LLM call's cost to BlackLake. Use when the proxy paths (/proxy/anthropic, /proxy/openai, /proxy/ollama) didn't see the call — Bedrock, Vertex, Foundry, Gemini, or any custom OpenAI-compatible runtime that ran under your own credentials. Pass evaluation_id to bind the cost to a govern() receipt; the v2 decision token then signs the cost summary alongside the decision.

bl.cost.record(body: CostRecordIngest): Promise<CostRecord>
const decision = await bl.govern({ agent: 'claude-prod', tool: 'github__create-issue' });

// ... your direct Bedrock / Vertex / Foundry call happens here ...

await bl.cost.record({
  evaluation_id: decision.evaluation_id,
  provider: 'bedrock',
  model: 'anthropic.claude-sonnet-4-6',
  input_tokens: 1820,
  output_tokens: 412,
  capture_path: 'sdk',
});

bl.cost.estimate()#

Pre-call cost estimation against the current pricing snapshot. Returns the full breakdown (input / output / cache_read / cache_write / thinking). Pass the result into bl.govern({ estimate: ... }) so cost-aware policies and budgets can deny before the spend leaves the network.

bl.cost.estimate(body: CostEstimateRequest): Promise<CostBreakdown>
const breakdown = await bl.cost.estimate({
  provider: 'anthropic',
  model: 'claude-opus-4-7',
  input_tokens: 12_000,
  output_ceiling_tokens: 4_000,
});

console.log(`Projected: $${breakdown.total_usd.toFixed(4)} (pricing v${breakdown.pricing_version})`);

bl.cost.summary()#

Workspace cost summary with provider / model / agent / tool / capture-path breakdowns. Backs the /usage console page. period defaults to 30d.

bl.cost.summary(period?: 'day' | '7d' | '30d' | '90d'): Promise<unknown>

bl.cost.byEvaluation()#

Pull every cost_record attached to one evaluation, plus the v2 decision token that binds the cost summary cryptographically. decision_token_v2 is null until the first cost record lands.

bl.cost.byEvaluation(evaluationId: string): Promise<CostByEvaluation>
const { summary, records, decision_token_v2 } = await bl.cost.byEvaluation(evalId);
if (decision_token_v2) {
  // Receipt is now v2 — bind it into your audit trail.
}

bl.cost.timeseries()#

Daily aggregation for the spend chart. Returns one row per day in the requested period — useful for the cost-over-time graph in the console.

bl.cost.timeseries(period?: 'day' | '7d' | '30d' | '90d'): Promise<{
  period: string;
  days: Array<{ day: string; cost_usd: number; calls: number }>;
}>

bl.cost.decomposition()#

Multi-level cost tree: agent → tool → model → cost-component (input / output / cache_read / cache_write / thinking). Used for cost-attribution audits.

bl.cost.decomposition(period?: 'day' | '7d' | '30d' | '90d'): Promise<unknown>

bl.cost.export()#

Streaming export of cost-attributed evaluations as CSV or NDJSON. Returns the raw body — pipe into jq, BigQuery, S3, or your finance pipeline.

bl.cost.export(
  format: 'csv' | 'ndjson',
  period?: '7d' | '30d' | '90d'
): Promise<string>

bl.cost.pricing()#

Full priced-model catalogue. Returns every (provider, model) entry in the requested pricing snapshot, tagged with whether it's an exact match or a longest-prefix fallback. Use this to spot pricing gaps before shipping a cost-aware policy.

bl.cost.pricing(version?: string): Promise<{
  version: string;
  current_version: string;
  available_versions: string[];
  rows: Array<{
    provider: CostProvider;
    model: string;
    match_kind: 'exact' | 'prefix';
    rates: {
      input_per_1m: number;
      output_per_1m: number;
      cache_read_per_1m: number;
      cache_write_per_1m: number;
      thinking_per_1m: number;
    };
  }>;
}>

bl.cost.orphans()#

Cost records that don't link to a governed evaluation (BL-FND-23). "Orphans" mean spend BlackLake recorded but can't tie back to a governance receipt — the actionable list of coverage gaps in cost attribution.

bl.cost.orphans(params?: {
  limit?: number;
  offset?: number;
  since?: string;
}): Promise<{
  data: CostRecord[];
  total: number;
  limit: number;
  offset: number;
  sort: string;
  order: 'asc' | 'desc';
}>

bl.budgets#

Soft + hard USD limits scoped to a workspace, agent, tool, or user. Hard limits deny at govern() time — before the LLM call runs. Soft limits fire budget.threshold_crossed webhooks at 50 / 80 / 100 % and budget.limit_exceeded at the hard cap. Concept overview at Budgets.

bl.budgets.list()#

bl.budgets.list(): Promise<Budget[]>

bl.budgets.create()#

bl.budgets.create(args: BudgetCreate): Promise<Budget>
await bl.budgets.create({
  name: 'Production Opus / month',
  scope_type: 'agent',
  scope_id: 'agent_…',
  period: 'month',
  timezone: 'Europe/London',
  soft_limit_usd: 800,
  hard_limit_usd: 1000,
});

scope_id is required for agent, tool, and user scopes; omitted for workspace. timezone defaults to UTC and accepts any IANA name (period boundaries roll over at local midnight).

bl.budgets.get()#

bl.budgets.get(id: string): Promise<Budget>

bl.budgets.status()#

Live state for the current period: spend_usd, percentages of soft and hard, and a coarse state enum that mirrors the webhook events.

bl.budgets.status(id: string): Promise<BudgetStatus>
const s = await bl.budgets.status(budgetId);
console.log(`${s.spend_usd} / ${s.hard_limit_usd} (${s.state})`);
// → 742.18 / 1000 (soft_80)

bl.budgets.statusFor()#

Alias for status(id) — preferred name in newer client code where the call reads as "status for this budget id". Identical return shape.

bl.budgets.statusFor(id: string): Promise<BudgetStatus>

bl.budgets.workspaceStatus()#

Org-wide budget rollup. Returns the current spend + limits for every enabled budget in the workspace — drives the dashboard "spend at a glance" card. Each row carries the friendly scope_name resolution alongside the scope_id and scope_type.

bl.budgets.workspaceStatus(): Promise<{
  budgets: Array<{
    id: string;
    name: string;
    scope_type: BudgetScope;
    scope_id: string | null;
    scope_name: string | null;
    period: BudgetPeriod;
    spend_usd: number;
    soft_limit_usd: number | null;
    hard_limit_usd: number;
  }>;
}>

bl.budgets.update()#

Partial update. Useful for raising a hard cap without recreating the budget (which would reset period_key accounting).

bl.budgets.update(id: string, patch: Partial<BudgetCreate>): Promise<Budget>

bl.budgets.delete()#

bl.budgets.delete(id: string): Promise<void>

bl.organisation#

Manage the current organisation. The organisation is derived from the API key — there is no organisation ID parameter.

bl.organisation.get()#

Fetch the current organisation.

bl.organisation.get(): Promise<Organisation>
const org = await bl.organisation.get();
console.log(org.name, org.contact_email);

bl.organisation.delete()#

Permanently delete the organisation and all associated data: agents, tools, policies, bindings, the evaluation audit log, and every API key. The deletion is irreversible. The caller must pass the organisation's exact name as confirmation — any other value is rejected with CONFIRMATION_MISMATCH. Optionally pass a free-text reason (surfaced to the BlackLake team in the churn notification email; not stored permanently).

bl.organisation.delete(confirmation: string, reason?: string): Promise<void>
const org = await bl.organisation.get();
await bl.organisation.delete(org.name, 'switching to a regulated tenant');

bl.organisation.reset()#

Wipe the workspace's operational data without deleting the workspace itself. Removes agents, tools, bindings, policies, evaluations, approvals, MCP upstreams, webhooks, audit ingest events, and cost logs. Preserves the org row, users, API keys, memberships, sessions, push subscriptions, invitations, and the GitHub installation handle — your existing keys and sessions keep working.

Same name-confirmation safeguard as delete(). Restricted to admins; rate-limited at 3/hour. Returns counts of rows removed per table — useful for tests that want to assert clean state.

bl.organisation.reset(confirmation: string, reason?: string): Promise<{
  reset_at: string;
  organisation_id: string;
  total_rows: number;
  counts: Record<string, number>;
  preserved: string[];
  note: string;
}>
const org = await bl.organisation.get();
const result = await bl.organisation.reset(org.name);
console.log(`Wiped ${result.total_rows} rows across ${Object.keys(result.counts).length} tables`);

bl.approvals#

bl.approvals.list()#

List approval records with optional filters. Returns a paginated response.

bl.approvals.list(params?: {
  status?: 'pending' | 'approved' | 'rejected' | 'expired';
  agent_id?: string;
  tool_id?: string;
  limit?: number;   // Default: 50
  offset?: number;  // Default: 0
}): Promise<PaginatedResponse<Approval>>
// All pending approvals
const { data, total } = await bl.approvals.list({ status: 'pending' });
console.log(`${data.length} of ${total} pending approvals`);

bl.approvals.get()#

Retrieve a single approval by ID.

bl.approvals.get(id: string): Promise<Approval>
const approval = await bl.approvals.get('approval_01j...');
console.log(approval.status, approval.expires_at);

bl.approvals.status()#

Fetch only the status fields for an approval. Cheaper than a full get() when polling.

bl.approvals.status(id: string): Promise<ApprovalStatusResponse>
const { status, decided_at, expires_at } = await bl.approvals.status('approval_01j...');

bl.approvals.approve()#

Approve a pending approval. Throws APPROVAL_ALREADY_DECIDED if it is not in pending status. Throws APPROVAL_EXPIRED if it has passed its expires_at.

bl.approvals.approve(
  id: string,
  data: { decided_by: string; reason?: string }
): Promise<Approval>
const approval = await bl.approvals.approve('approval_01j...', {
  decided_by: 'ops-team',
  reason: 'Verified vendor and amount are correct',
});
console.log(approval.status); // 'approved'

bl.approvals.reject()#

Reject a pending approval. Same error conditions as approve().

bl.approvals.reject(
  id: string,
  data: { decided_by: string; reason?: string }
): Promise<Approval>
const approval = await bl.approvals.reject('approval_01j...', {
  decided_by: 'ops-team',
  reason: 'Amount exceeds policy limit without CFO sign-off',
});
console.log(approval.status); // 'rejected'

bl.approvals.breakGlass()#

Emergency override. Force-approves an approval regardless of requires_two_person. Sets break_glass: true on the approval and records action: 'break-glass' on the decisions ledger so the audit trail makes the override obvious. The reason must be at least 40 characters — the friction is the point.

bl.approvals.breakGlass(
  id: string,
  args: { decided_by: string; reason: string }
): Promise<Approval>
const approval = await bl.approvals.breakGlass('approval_01j...', {
  decided_by: 'oncall@acme.example',
  reason:
    'Production incident — bypassing two-person approval to roll back the bad release immediately.',
});

bl.approvals.wait()#

Poll the approval until it reaches a terminal status (approved, rejected, or expired) and return the final Approval object. Throws BlackLakeError with code APPROVAL_WAIT_TIMEOUT (status 408) if the timeout elapses before a decision is made.

bl.approvals.wait(
  id: string,
  options?: {
    interval?: number;  // Poll interval in ms. Default: 2000.
    timeout?: number;   // Total wait time in ms. Default: 300000 (5 minutes).
  }
): Promise<Approval>
try {
  const approval = await bl.approvals.wait('approval_01j...', {
    interval: 3000,
    timeout: 60_000, // 1 minute
  });

  if (approval.status === 'approved') {
    await executeAction();
  } else {
    console.log(`Not approved: ${approval.decision_reason}`);
  }
} catch (err) {
  if (err instanceof BlackLakeError && err.code === 'APPROVAL_WAIT_TIMEOUT') {
    console.log('Timed out waiting for approval. Use webhooks for long-running workflows.');
  }
  throw err;
}

bl.webhooks#

bl.webhooks.list()#

List all webhooks registered for the organisation.

bl.webhooks.list(): Promise<{ webhooks: Webhook[] }>
const { webhooks } = await bl.webhooks.list();
const active = webhooks.filter(w => w.enabled);

bl.webhooks.create()#

Register a new webhook. The secret field in the response is the raw signing secret — it is shown once and cannot be retrieved again. Store it securely.

bl.webhooks.create(data: {
  url: string;
  events: Array<'approval.created' | 'approval.approved' | 'approval.rejected'>;
  enabled?: boolean;  // Default: true
}): Promise<CreatedWebhook>
const wh = await bl.webhooks.create({
  url: 'https://myapp.example/webhooks/blacklake',
  events: ['approval.created', 'approval.approved', 'approval.rejected'],
});

console.log(wh.secret); // save this — not shown again

bl.webhooks.get()#

Retrieve a single webhook by ID. Does not return the secret.

bl.webhooks.get(id: string): Promise<Webhook>
const wh = await bl.webhooks.get('wh_01j...');
console.log(wh.url, wh.enabled);

bl.webhooks.update()#

Update webhook fields. Only provided fields are changed.

bl.webhooks.update(
  id: string,
  data: Partial<{
    url: string;
    events: WebhookEvent[];      // see "Webhook event names" below for the full list
    enabled: boolean;
  }>
): Promise<Webhook>
// Disable a webhook temporarily
const wh = await bl.webhooks.update('wh_01j...', { enabled: false });

bl.webhooks.delete()#

Permanently delete a webhook. Returns void.

bl.webhooks.delete(id: string): Promise<void>
await bl.webhooks.delete('wh_01j...');

bl.webhooks.listDeliveries()#

List delivery attempts for a webhook. Returns the standard list envelope. Useful for debugging failed deliveries.

bl.webhooks.listDeliveries(
  id: string,
  params?: {
    limit?: number;
    offset?: number;
    sort?: 'delivered_at' | 'status_code' | 'duration_ms';
    order?: 'asc' | 'desc';
  }
): Promise<{
  data: WebhookDelivery[];
  total: number;
  limit: number;
  offset: number;
  sort: string;
  order: 'asc' | 'desc';
}>
const { data, total } = await bl.webhooks.listDeliveries('wh_01j...', { limit: 10 });
for (const d of data) {
  console.log(`${d.event}  status=${d.status_code ?? 'error'}  ${d.delivered_at}`);
}

bl.webhooks.test()#

Fire a synthetic delivery against this webhook to validate routing and signing end-to-end. The delivery is persisted to webhook_deliveries so it shows up in listDeliveries() alongside real fan-outs (tagged as a test).

bl.webhooks.test(id: string): Promise<{
  delivery_id: string;
  status_code: number | null;
  response_body: string | null;
  error: string | null;
  duration_ms: number | null;
  delivered_at: string;
}>

bl.webhooks.resendDelivery()#

Re-fire a previously-attempted delivery. Re-uses the original payload but re-signs with the current secret and a fresh timestamp — rotated secrets work, replay-window checks don't reject as stale. Creates a new delivery row tagged replay_of with the original delivery id.

bl.webhooks.resendDelivery(id: string, deliveryId: string): Promise<{
  delivery_id: string;
  replay_of: string;
}>

bl.webhooks.resendFailedDeliveries()#

Bulk-replay every failed delivery for this webhook (status null OR >= 400 OR error non-null). Capped at 100 server-side; each redelivery uses a fresh timestamp and the current secret. Returns 422 NO_FAILED_DELIVERIES if there is nothing to replay.

bl.webhooks.resendFailedDeliveries(id: string): Promise<{
  resent: number;
  deliveries: Array<{ original_id: string; new_id: string }>;
}>

bl.webhooks.rotateSecret()#

Issue a new HMAC signing secret. Returns the raw secret once — same shape as create(). The previous secret is invalidated atomically; update your receiver before the next delivery fires. Use for periodic rotation or after a suspected leak.

bl.webhooks.rotateSecret(id: string): Promise<{
  id: string;
  secret: string;
  secret_suffix: string;
  warning: string;
}>

bl.webhooks.health()#

Delivery health over the last 100 deliveries (the window_size). Returns the success rate, last successful delivery timestamp, and the count of deliveries marked dead by an operator who stopped retrying.

bl.webhooks.health(id: string): Promise<{
  webhook_id: string;
  window_size: number;        // up to 100
  success_rate: number;       // 0–1
  last_success_at: string | null;
  dead_letter_count: number;
}>

bl.webhooks.deadLetter()#

Dead-letter view: deliveries explicitly tagged status='dead' because an operator gave up retrying. Different from listDeliveries({status:'failed'}) — that's everything that ever failed; this is only the ones an operator stopped chasing.

bl.webhooks.deadLetter(
  id: string,
  params?: { limit?: number; offset?: number }
): Promise<{ data: WebhookDelivery[]; total: number; limit: number; offset: number; sort: string; order: 'asc' | 'desc' }>

Webhook verification#

Verify incoming webhook signatures before trusting the payload. Use the signing secret returned at creation time. verifyWebhook is exported from the top-level blacklake package — no need to hand-roll HMAC.

import { verifyWebhook } from 'blacklake';

// Express / Hono / Next.js — pass the raw body and the request headers map.
// Throws BlackLakeError (code: WEBHOOK_SIGNATURE_INVALID) on mismatch.
await verifyWebhook({
  secret: process.env.WEBHOOK_SECRET!,
  rawBody,                  // the body bytes as a string, BEFORE any JSON.parse
  headers: req.headers,     // includes x-blacklake-signature + x-blacklake-timestamp
});

If you already have the BlackLake client, the same check is available as a static method:

import { BlackLake } from 'blacklake';

await BlackLake.verifyWebhookSignature({
  secret: process.env.WEBHOOK_SECRET!,
  rawBody,
  signature: req.headers['x-blacklake-signature'] as string,
  timestamp: req.headers['x-blacklake-timestamp'] as string,
});

Webhook event names

Set events: ['*'] to receive every event, or filter to specific names:

EventWhen it fires
evaluation.createdEvery successful govern() — covers allow, deny, and approval_required
evaluation.deniedSubset of evaluation.created where the outcome is deny
evaluation.approval_requiredSubset where the outcome paused for human approval
approval.createdA new approval request was raised
approval.approvedAn approval was approved (including break-glass)
approval.rejectedAn approval was rejected
budget.threshold_crossedBudget crossed its warn threshold (e.g. 80%)
budget.limit_exceededBudget exceeded its hard limit — future govern() calls will deny
cost.recordedA cost record was attached to an evaluation or /proxy/* call
upstream.unhealthyAn MCP upstream tripped its health check
upstream.recoveredA previously-unhealthy MCP upstream recovered

The audit export uses a different label (evaluation / approval / action_result) — webhook events follow the table above. Do not subscribe to evaluation.recorded; that name only appears inside audit NDJSON, not on the webhook stream.


bl.apiKeys#

Manage the API keys for the current organisation.

bl.apiKeys.list()#

List API keys (active and revoked). Returns the standard list envelope — keys are in .data. Never returns the raw key or its hash — only the last four characters as key_suffix for identification.

bl.apiKeys.list(params?: {
  limit?: number;
  offset?: number;
  sort?: 'created_at' | 'name' | 'revoked_at';
  order?: 'asc' | 'desc';
}): Promise<{
  data: ApiKey[];
  total: number;
  limit: number;
  offset: number;
  sort: string;
  order: 'asc' | 'desc';
}>

interface ApiKey {
  id: string;
  name: string;
  key_suffix: string | null;
  created_at: string;
  revoked_at: string | null;
}
const { data: keys } = await bl.apiKeys.list();
const active = keys.filter(k => !k.revoked_at);

bl.apiKeys.create()#

Generate a new API key. The raw key is returned once in the key field — store it immediately. After this call, the raw key cannot be retrieved again.

bl.apiKeys.create(name: string): Promise<CreatedApiKey>

interface CreatedApiKey {
  id: string;
  name: string;
  key: string;        // The raw bl_-prefixed key. Shown ONCE.
  created_at: string;
  warning: string;
}
const newKey = await bl.apiKeys.create('production-rotated');
process.env.BLACKLAKE_API_KEY = newKey.key;  // store securely

bl.apiKeys.revoke()#

Revoke an API key by ID. Subsequent requests using that key will return 401 UNAUTHORIZED. The API rejects attempts to revoke the key currently being used to make the call (CANNOT_REVOKE_SELF).

bl.apiKeys.revoke(id: string): Promise<void>
await bl.apiKeys.revoke('key_01j...');

bl.audit#

Stream the audit ledger out of BlackLake. Useful for evidence packs, BigQuery loads, SIEM ingestion, or just an investigation.

bl.audit.export()#

Export the org's audit ledger as newline-delimited JSON. Each line is { "type": "evaluation" | "approval" | "action_result", "data": {...} }. The window is capped server-side at 365 days; for longer ranges, call repeatedly with adjacent windows.

bl.audit.export(params?: {
  from?: Date | string;
  to?: Date | string;
  kinds?: Array<'evaluation' | 'approval' | 'action_result'>;
  includeArchived?: boolean;
}): Promise<string>

includeArchived: true unions GCS cold-storage rows with the live Postgres rows (BL-OPS-4b). Without this flag only Postgres rows return — identical to the pre-BL-OPS-4b behaviour. At the hot/cold boundary global sort order isn't guaranteed; re-sort client-side if strict time-series order matters.

const ndjson = await bl.audit.export({
  from: new Date('2026-04-01'),
  to: new Date('2026-04-30'),
  kinds: ['evaluation', 'action_result'],
});

await fs.writeFile('blacklake-april.ndjson', ndjson);

bl.audit.ingest()#

Ingest one external audit event from an outside source (GCP, AWS, Azure, GitHub, …). The event is reconciled against the recent governance log — a match populates governed_evaluation_id, no match leaves the event "uncovered" (see listUncovered()).

bl.audit.ingest(event: {
  source: 'gcp' | 'aws' | 'azure' | 'github' | 'other';
  source_event_id?: string;
  event_type?: string;
  resource?: string;
  principal?: string;
  action?: string;
  occurred_at?: string;
  payload?: Record<string, unknown>;
}): Promise<ExternalEvent>

Dedup is at-most-once on (organisation_id, source, source_event_id) when the latter is provided — webhook retries are safe to replay; duplicates return 409 AUDIT_EVENT_DUPLICATE.

bl.audit.listEvents()#

Recent ingested external events. Filter by source; standard limit/offset pagination.

bl.audit.listEvents(params?: {
  source?: string;
  limit?: number;
  offset?: number;
}): Promise<{
  data: ExternalEvent[];
  total: number;
  limit: number;
  offset: number;
  sort: string;
  order: 'asc' | 'desc';
}>

bl.audit.listUncovered()#

External events that didn't reconcile to a governed evaluation — the actionable inbox. Each row is a real action your stack took that BlackLake didn't see, i.e. a coverage gap.

bl.audit.listUncovered(params?: {
  limit?: number;
  offset?: number;
}): Promise<{
  data: ExternalEvent[];
  total: number;
  limit: number;
  offset: number;
  sort: string;
  order: 'asc' | 'desc';
}>

bl.system#

System and identity calls — mode/health/me/quota. None of these read or write workspace resources; they answer "where am I" and "who am I" before the rest of the SDK does its work.

bl.system.mode()#

bl.system.mode(): Promise<{ mode: 'local' | 'cloud'; api_key?: string }>

Detect whether the API is running in local or cloud mode. In local mode the response includes the auto-generated local API key so the console can auto-authenticate. Unauthenticated — safe to call before constructing the client.

bl.system.health()#

bl.system.health(): Promise<{ status: 'ok' }>

Liveness check. Returns { status: 'ok' } when the API is reachable.

bl.system.me()#

bl.system.me(): Promise<{
  auth_mode: 'session' | 'api_key';
  organisation: { id: string; name: string };
  session: {
    user_id: string;
    user: { id: string; email: string; email_verified_at: string | null; created_at: string };
  } | null;
  api_key: {
    id: string;
    name: string | null;
    key_suffix: string;
    user_id: string | null;
    user: { id: string; email: string } | null;
    created_at: string;
  } | null;
}>

Identify the calling actor. Works with both auth modes — a unified envelope is returned regardless. Exactly one of session or api_key will be populated; the other is null. api_key.user is populated for user-scoped keys (bl_usr_…) and null for org-scoped keys.

bl.system.quota()#

bl.system.quota(): Promise<{
  tier: 'free' | 'team' | 'enterprise';
  status: 'no_subscription' | 'trialing' | 'active' | 'past_due' | 'canceled' | 'unpaid';
  quantity: number;
  stripe_customer_id: string | null;
  stripe_subscription_id: string | null;
  stripe_price_id: string | null;
  current_period_start: string | null;
  current_period_end: string | null;
  cancel_at_period_end: boolean;
  canceled_at: string | null;
  trial_end: string | null;
  usage: { used: number; limit: number | null; remaining: number | null; period_key: string };
}>

Subscription summary plus current-period usage. Mirrors what the console reads to render the quota meter and the Billing page. usage.limit is 10000 on free tier and null on Team / Enterprise (uncapped). stripe_* fields are null on free until a checkout completes.


bl.mcp#

MCP server inventory and credential rotation. Distinct from the MCP proxy (which routes tool calls) — this namespace just manages the upstream catalogue. For per-endpoint detail (request/response shapes, error codes) see API reference → MCP Upstreams.

bl.mcp.list()#

bl.mcp.list(): Promise<{ servers: Array<{ name: string; connected: boolean; tool_count: number; error?: string | null }> }>

List configured MCP upstream servers and their live connection status. Reads the local-mode in-memory registry — for the persistent cloud catalogue use bl.mcp.upstreams.list() below.

bl.mcp.reconnect(name)#

bl.mcp.reconnect(name: string): Promise<{ name: string; connected: boolean; tool_count: number; error?: string | null }>

Trigger an immediate reconnect for a named MCP upstream. Returns the post-reconnect status.

bl.mcp.rotate(upstreamId, options?)#

bl.mcp.rotate(
  upstreamId: string,
  options?: { headers?: Record<string, string> }
): Promise<{ rotation: 'headers_rotated' | 'oauth_reauth_required'; upstream_id: string; authorization_url?: string; upstream?: McpUpstream; message: string }>

Rotate credentials for an upstream without losing the row or its bindings. For static_headers upstreams: pass { headers }; the stored headers are replaced. For oauth2 upstreams: headers is ignored, the calling user's token is cleared, and a fresh PKCE flow is minted (requires session auth; org-key callers get a 401 with USER_AUTH_REQUIRED).

bl.mcp.upstreams — persistent cloud catalogue#

bl.mcp.upstreams.list(): Promise<{ upstreams: McpUpstream[] }>
bl.mcp.upstreams.get(id: string): Promise<McpUpstream>
bl.mcp.upstreams.test(id: string): Promise<{ ok: boolean; duration_ms: number; tool_count: number | null; error: string | null }>
bl.mcp.upstreams.health(id: string): Promise<{ is_healthy: boolean; consecutive_failure_count: number; last_status_at: string | null; last_error: string | null; uptime_24h: number; pings: Array<{ ts: string; ok: boolean; duration_ms: number | null }> }>

Org-scoped, persistent upstream catalogue (distinct from bl.mcp.list() above). test probes the upstream synchronously. health returns the rolling sparkline + last-error used by the dashboard.


bl.insights#

Analytics surfaces. Each method maps to one /v1/insights/* endpoint. The console's /insights, /anomalies, /risk, /coverage, and /observations pages all read from this namespace.

bl.insights.coverage()#

bl.insights.coverage(): Promise<CoverageInsights>

Coverage dashboard — actors + tools + capture-path attribution.

bl.insights.risk()#

bl.insights.risk(): Promise<RiskInsights>

Risk dashboard — decision breakdown, top deniers, high-risk tools.

bl.insights.healthSnapshot()#

bl.insights.healthSnapshot(): Promise<HealthSnapshot>

7-day workspace digest: cost total, top agents, anomaly count, decision breakdown. Same payload the lifecycle digest email renders.

bl.insights.drift()#

bl.insights.drift(): Promise<DriftReport>

Workspace cost change vs prior window with hypothesis hints.

bl.insights.anomalies(opts?)#

bl.insights.anomalies(opts?: { includeDismissed?: boolean; limit?: number }): Promise<{ anomalies: ObservationRow[] }>

Active anomalies. includeDismissed: true returns dismissed rows too.

bl.insights.recomputeAnomalies(windowDays?)#

bl.insights.recomputeAnomalies(windowDays = 7): Promise<{ ok: boolean; detected: number; inserted: number; deduped: number }>

Re-detect anomalies over the given window.

bl.insights.dismissAnomaly(id)#

bl.insights.dismissAnomaly(id: string): Promise<void>

Dismiss a single anomaly so it stops surfacing on the dashboard.

bl.insights.observations(opts?)#

bl.insights.observations(opts?: { kind?: string; limit?: number }): Promise<{ observations: ObservationRow[] }>

Workspace observation feed — anomalies, drift, hints, gaps.

bl.insights.baselines(windowDays?) / recomputeBaselines(windowDays?)#

bl.insights.baselines(windowDays = 30): Promise<unknown>
bl.insights.recomputeBaselines(windowDays = 30): Promise<unknown>

Per-(agent, tool) baselines — token + cost percentiles, model split.

bl.insights.modelChoice(windowDays?)#

bl.insights.modelChoice(windowDays = 30): Promise<ModelChoiceReport>

Per-(agent, tool) model usage comparison. Only pairs with 2+ models are returned.

bl.insights.modelSubstitution(args)#

bl.insights.modelSubstitution(args: { from: string; to: string; windowDays?: number }): Promise<ModelSubstitutionReport>

Counterfactual: "if every from call had used to, what would the cost have been?" Equivalence is left to the caller to judge.

bl.insights.coverageTrend(windowDays?)#

bl.insights.coverageTrend(windowDays?: number): Promise<{
  window_days: number;
  series: Array<{ day: string; total: number; by_source: Record<string, number> }>;
}>

Densified per-day series of governed evaluations broken down by actor source. Days with zero traffic are emitted with total: 0 so the caller doesn't have to fill gaps. Default 30, clamped to [1, 180].

bl.insights.explain(evaluationId)#

bl.insights.explain(evaluationId: string): Promise<{
  evaluation_id: string;
  decision: 'allow' | 'deny' | 'approval_required' | 'default_deny';
  decided_at: string;
  actual_match: { policy_id: string; name: string; outcome: string } | null;
  counterfactual: { would_match: string; name: string; outcome: string; priority: number; note: string } | null;
  policies_considered: Array<{
    policy_id: string; name: string; priority: number; outcome: string;
    enabled: boolean; mode: 'enforce' | 'monitor';
    agent_selector: Record<string, unknown> | null;
    tool_selector: Record<string, unknown> | null;
    agent_match: { matched: boolean; expected?: string; actual?: string };
    tool_match: { matched: boolean; expected?: string; actual?: string };
  }>;
}>

Why did this evaluation decide the way it did? Returns the matched policy (if any), every other policy considered with its per-selector match result, and a counterfactual block showing the next policy that would have decided differently.


Types#

type ActorSource = 'manual' | 'mcp' | 'sdk' | 'ci';

interface Agent {
  id: string;
  organisation_id: string;
  name: string;
  description: string | null;
  environment: 'development' | 'staging' | 'production';
  risk_classification: 'low' | 'medium' | 'high' | 'critical';
  status: 'active' | 'suspended' | 'disabled';
  approval_mode: 'auto_approve' | 'require_approval' | 'block';
  owner: string | null;
  source: ActorSource;
  last_seen_at: string | null;
  created_at: string;
  updated_at: string;
}

interface Tool {
  id: string;
  organisation_id: string;
  name: string;
  description: string | null;
  risk_classification: 'low' | 'medium' | 'high' | 'critical';
  owner: string | null;
  source: ActorSource;
  last_seen_at: string | null;
  created_at: string;
}

interface Policy {
  id: string;
  organisation_id: string;
  name: string;
  priority: number;
  agent_selector: Record<string, unknown>;
  tool_selector: Record<string, unknown>;
  outcome: 'allow' | 'deny' | 'approval_required';
  enabled: boolean;
  requires_two_person: boolean;
  /** When non-empty, a session-authed approver must hold at least one
   *  matching role in their workspace membership. */
  approver_roles: string[] | null;
  created_at: string;
  updated_at: string;
}

type PolicySnapshot = Omit<Policy, 'organisation_id'>;

interface Evaluation {
  id: string;
  organisation_id: string;
  agent_id: string;
  tool_id: string;
  policy_id: string | null;
  policy_name: string | null;
  policy_priority: number | null;
  policy_snapshot: PolicySnapshot | null;
  action_payload: Record<string, unknown> | null;
  decision: 'allow' | 'deny' | 'approval_required' | 'default_deny';
  outcome: 'allow' | 'deny' | 'approval_required' | 'default_deny';
  evaluated_at: string;
  request_context: Record<string, unknown> | null;
}

type ActionResultStatus = 'succeeded' | 'failed' | 'skipped' | 'unknown';

interface ActionResult {
  id: string;
  organisation_id: string;
  evaluation_id: string;
  status: ActionResultStatus;
  external_system: string | null;
  external_id: string | null;
  external_url: string | null;
  duration_ms: number | null;
  exit_code: number | null;
  output_digest: string | null;
  error: string | null;
  metadata: Record<string, unknown> | null;
  recorded_at: string;
}

interface CreateActionResultRequest {
  status: ActionResultStatus;
  external_system?: string;
  external_id?: string;
  external_url?: string;
  duration_ms?: number;
  exit_code?: number;
  output_digest?: string;
  error?: string;
  metadata?: Record<string, unknown>;
}

type VerifyDecisionResult =
  | {
      valid: true;
      evaluation_id: string;
      decision: 'allow' | 'deny' | 'approval_required' | 'default_deny';
      agent_id: string;
      tool_id: string;
      policy_id: string | null;
      policy_name: string | null;
      policy_priority: number | null;
      policy_snapshot: PolicySnapshot | null;
      action_results: ActionResult[];
      evaluated_at: string;
    }
  | {
      valid: false;
      reason: 'evaluation_not_found' | 'malformed' | 'signature_mismatch';
    };

interface PaginatedResponse<T> {
  data: T[];
  total: number;
}

interface Organisation {
  id: string;
  name: string;
  contact_email: string | null;
  created_at: string;
}

interface ToolBinding {
  binding_id: string;
  binding_created_at: string;
  tool: Tool;
}

interface ApiKey {
  id: string;
  name: string;
  key_suffix: string | null;
  created_at: string;
  revoked_at: string | null;
}

interface CreatedApiKey {
  id: string;
  name: string;
  key: string;
  created_at: string;
  warning: string;
}

type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired';

interface ApprovalDecisionRecord {
  decided_by: string;
  action: 'approve' | 'reject' | 'break-glass';
  reason: string;
  decided_at: string;
}

interface Approval {
  id: string;
  organisation_id: string;
  evaluation_id: string;
  agent_id: string;
  tool_id: string;
  policy_id: string | null;
  action_payload: Record<string, unknown> | null;
  request_context: Record<string, unknown> | null;
  status: ApprovalStatus;
  decided_by: string | null;
  decision_reason: string | null;
  decided_at: string | null;
  requires_two_person: boolean;
  decisions: ApprovalDecisionRecord[] | null;
  break_glass: boolean;
  created_at: string;
  expires_at: string;
}

interface ApprovalStatusResponse {
  status: ApprovalStatus;
  decided_at: string | null;
  expires_at: string;
}

type WebhookEvent = 'approval.created' | 'approval.approved' | 'approval.rejected';

interface Webhook {
  id: string;
  organisation_id: string;
  url: string;
  secret_suffix: string | null;
  events: WebhookEvent[];
  enabled: boolean;
  created_at: string;
}

interface CreatedWebhook {
  id: string;
  url: string;
  secret: string;        // Raw signing secret. Shown ONCE.
  secret_suffix: string;
  events: WebhookEvent[];
  enabled: boolean;
  created_at: string;
  warning: string;
}

interface WebhookDelivery {
  id: string;
  webhook_id: string;
  event: WebhookEvent;
  payload: Record<string, unknown>;
  status_code: number | null;     // null on network error
  response_body: string | null;   // truncated to 500 chars
  error: string | null;           // network/timeout error message
  delivered_at: string;
  duration_ms: number | null;
}

type CostProvider =
  | 'anthropic'
  | 'openai'
  | 'bedrock'
  | 'vertex'
  | 'foundry'
  | 'gemini'
  | 'ollama'
  | 'unknown';

type CapturePath = 'proxy' | 'sdk' | 'mcp' | 'ci' | 'manual';

interface CostBreakdown {
  pricing_version: string;
  input_usd: number;
  output_usd: number;
  cache_read_usd: number;
  cache_write_usd: number;
  thinking_usd: number;
  total_usd: number;
  rates: {
    input_per_1m: number;
    output_per_1m: number;
    cache_read_per_1m: number;
    cache_write_per_1m: number;
    thinking_per_1m: number;
  };
}

interface CostRecord {
  id: string;
  organisation_id: string;
  evaluation_id: string | null;
  provider: CostProvider;
  model: string;
  input_tokens: number;
  output_tokens: number;
  cache_read_tokens: number;
  cache_write_tokens: number;
  thinking_tokens: number;
  cost_usd: number;
  pricing_version: string;
  capture_path: CapturePath;
  created_at: string | null;
}

interface CostRecordIngest {
  evaluation_id?: string;
  agent?: string;
  agent_id?: string;
  tool?: string;
  tool_id?: string;
  user_id?: string;
  provider: CostProvider;
  model: string;
  input_tokens: number;
  output_tokens: number;
  cache_read_tokens?: number;
  cache_write_tokens?: number;
  thinking_tokens?: number;
  duration_ms?: number;
  capture_path?: CapturePath;
  environment?: string;
  session_id?: string;
  task_id?: string;
  pricing_version?: string;
}

interface CostEstimateRequest {
  provider: CostProvider;
  model: string;
  input_tokens: number;
  output_ceiling_tokens: number;
  pricing_version?: string;
}

interface CostByEvaluation {
  evaluation_id: string;
  receipt_version: number;
  summary: {
    total_usd: number;
    input_tokens: number;
    output_tokens: number;
    cache_read_tokens: number;
    cache_write_tokens: number;
    thinking_tokens: number;
    pricing_version: string;
    record_count: number;
  } | null;
  records: CostRecord[];
  /** v2 decision token (binds cost). null until cost has been captured. */
  decision_token_v2: string | null;
}

type BudgetScope = 'workspace' | 'agent' | 'tool' | 'user';
type BudgetPeriod = 'per_task' | 'day' | 'week' | 'month';

interface Budget {
  id: string;
  organisation_id: string;
  name: string;
  scope_type: BudgetScope;
  scope_id: string | null;
  period: BudgetPeriod;
  timezone: string;
  soft_limit_usd: number | null;
  hard_limit_usd: number;
  enabled: boolean;
  created_at: string | null;
  updated_at: string | null;
}

interface BudgetCreate {
  name: string;
  scope_type: BudgetScope;
  scope_id?: string;
  period: BudgetPeriod;
  timezone?: string;
  soft_limit_usd?: number;
  hard_limit_usd: number;
  enabled?: boolean;
}

interface BudgetStatus {
  budget_id: string;
  period_key: string;
  spend_usd: number;
  soft_limit_usd: number | null;
  hard_limit_usd: number;
  soft_pct: number | null;
  hard_pct: number;
  state: 'ok' | 'soft_50' | 'soft_80' | 'over_soft' | 'over_hard';
}

Error Handling#

All SDK methods throw BlackLakeError on non-2xx responses.

class BlackLakeError extends Error {
  readonly status: number;  // HTTP status code
  readonly code: string;    // Machine-readable error code
  readonly message: string; // Human-readable explanation
}
import { BlackLake, BlackLakeError } from 'blacklake';

try {
  await bl.agents.get('agent_does_not_exist');
} catch (err) {
  if (err instanceof BlackLakeError) {
    console.error(`[${err.status}] ${err.code}: ${err.message}`);
    // [404] AGENT_NOT_FOUND: Agent agent_does_not_exist not found
  } else {
    throw err;
  }
}

Common error codes:

CodeStatusDescription
UNAUTHORIZED401Missing or invalid API key.
VALIDATION_ERROR400Request body failed schema validation. The message identifies the field.
AGENT_NOT_FOUND404The agent ID or name does not exist in this organisation.
TOOL_NOT_FOUND404The tool ID or name does not exist in this organisation.
POLICY_NOT_FOUND404The policy ID does not exist in this organisation.
EVALUATION_NOT_FOUND404The evaluation ID does not exist in this organisation.
ORG_NOT_FOUND404The organisation does not exist (typically only after deletion).
API_KEY_NOT_FOUND404The API key ID does not exist in this organisation.
BINDING_EXISTS409The agent-tool binding already exists.
CONFIRMATION_MISMATCH400The deletion confirmation string does not match the organisation name.
CANNOT_REVOKE_SELF400Cannot revoke the API key currently being used to make the request.
APPROVAL_NOT_FOUND404The approval ID does not exist in this organisation.
APPROVAL_ALREADY_DECIDED400The approval has already been approved or rejected.
APPROVAL_EXPIRED400The approval has passed its 24-hour expiry and can no longer be decided.
WEBHOOK_NOT_FOUND404The webhook ID does not exist in this organisation.
APPROVAL_WAIT_TIMEOUT408bl.approvals.wait() timed out before the approval reached a terminal status.
RATE_LIMITED429Per-key or per-IP rate limit exceeded. Retry after the Retry-After header.
PAYLOAD_TOO_LARGE413Request body exceeds the per-route size limit (8KB on /v1/govern, 32KB elsewhere).
INTERNAL_SERVER_ERROR500Unexpected server error. Should not happen in normal operation.