Docs

TypeScript client

Connect a Node.js agent to the K-Block Gateway with @kblockai/client: call tools, wait for approvals and handle every error.

@kblockai/client connects an existing Node.js agent to the Gateway. The Gateway is an MCP server (Model Context Protocol over Streamable HTTP) that stands in front of your tools: it checks the agent's API key, evaluates your organization's policy, holds calls for human approval, executes approved calls exactly once and records a signed receipt.

The client decides nothing itself. It sends the agent's key, adds the Gateway's two _meta conventions (an idempotency key and an approval wait) and turns every answer into a typed result or a typed error. Use it when your agent is written in TypeScript or JavaScript and you want K-Block's outcomes (completed, held for approval, denied with the policy's explanation…) as values your code can branch on.

Install

npm install --save-exact @kblockai/client@0.1.0-rc.1
RequirementValue
Node.js22 or later (engines.node is >=22)
Module formatESM only, TypeScript declarations included
Runtime dependency@modelcontextprotocol/sdk ^1.30.1 only
Optional peer@langchain/core >=1.0.0 <2, only for @kblockai/client/langchain

The client does not depend on @kblockai/sdk. You need that package only to verify receipts.

Before you connect you need two values:

  • A gateway API key for the agent. In the dashboard, open API keys and choose New API key. Keep the kind Gateway key (only owners and admins are offered a choice), enter a Name, choose the Environment and the Agent, then Create key. Owners, admins and developers can create gateway keys. The full key (kb_live_… or kb_test_…, by environment) is shown once. See API keys and Set up an agent.
  • The Gateway URL that K-Block gives you, for example https://gateway.example.com. The dashboard shows the Gateway's MCP endpoint right after you create a gateway API key. If it does not show it, contact K-Block support.

Connect to the Gateway

connectKBlock checks the key's format, opens the MCP connection and returns a KBlockClient. The examples on this page read the Gateway URL and the API key from two environment variables of their own (KBLOCK_GATEWAY_URL, KBLOCK_API_KEY); your application can pass them from anywhere.

connect.ts
import { connectKBlock } from "@kblockai/client";

const client = await connectKBlock({
  gatewayUrl: process.env.KBLOCK_GATEWAY_URL ?? "", // e.g. https://gateway.example.com
  apiKey: process.env.KBLOCK_API_KEY ?? "", // the agent's gateway API key
});

try {
  // Only the tools the agent's delegation grants are listed.
  const tools = await client.listTools();
  console.log(tools.map((t) => t.name));
} finally {
  await client.close();
}

What connectKBlock checks before anything reaches the network:

  • The key format. A key that is not kb_<live|test>_<12 characters>_<43 characters> throws KBlockAuthError with code MALFORMED_API_KEY, and nothing is sent. The message hints at the usual mistakes (an empty value, or a trailing newline from a file or an environment variable). The secret part of a key never appears in a message.
  • The URL. It must be http: or https:, without credentials, query or fragment. You can pass the base URL or the /mcp endpoint; the client appends /mcp when it is missing. Plain http: is allowed only to localhost, 127.x.x.x and [::1]; anything else throws a TypeError unless you set allowInsecureHttp: true (the key would travel unencrypted).

How the key is protected on the wire:

  • Every request carries Authorization: Bearer <key>, and only to the Gateway endpoint's own origin. A request to any other origin throws KBlockHttpError with status 0 before it is sent.
  • Redirects are never followed. A 3xx answer throws KBlockHttpError; point gatewayUrl at the Gateway itself, not at a URL that redirects.

client.mcp is the connected MCP Client from @modelcontextprotocol/sdk if you need anything beyond tool calls, and client.endpoint is the resolved /mcp URL.

Call a tool

callTool(name, args, options?) sends one tools/call and returns a KBlockCallResult, a union discriminated by status. Refusals are results, not exceptions: only transport problems throw (see Errors).

call.ts
import { connectKBlock, describePolicy, explainResult } from "@kblockai/client";

const client = await connectKBlock({
  gatewayUrl: process.env.KBLOCK_GATEWAY_URL ?? "",
  apiKey: process.env.KBLOCK_API_KEY ?? "",
});

// For example, a refund tool that your policy may allow, hold for approval or deny.
const r = await client.callTool("refund", { order_id: "ord_123", amount: "250.00", currency: "USD" });

switch (r.status) {
  case "completed":
    console.log("result:", r.structuredContent ?? r.content);
    console.log("action:", r.actionId, "receipt:", r.receiptId);
    break;
  case "approval_pending":
    console.log(`approval request ${r.approvalRequestId} is open until ${r.expiresAt ?? "(not given)"}`);
    break;
  case "denied":
    console.log("denied:", r.code, r.detail);
    if (r.policy !== undefined) console.log("decided by", describePolicy(r.policy));
    break;
  default:
    // rejected, expired, cancelled, unavailable, upstream_error, invalid_request, …
    console.log(explainResult(r));
}

await client.close();

Arguments are a JSON object whose numbers must be integers (within JavaScript's safe integer range). Pass decimals, such as amounts, as strings ("250.00"): a call with a number like 250.5 is refused as invalid_request with code INVALID_ARGUMENTS, and nothing is recorded or run.

Outcomes

statusMeaningDid the tool run?
completedThe Gateway executed the call. content / structuredContent are the tool's output; actionId and receiptId identify the recorded action and its receipt.Yes
approval_pendingA person must approve this exact call first. approvalRequestId and actionId are always present.No
deniedThe policy, or a check outside the policy, refused the call.No
rejected, expired, cancelledA recorded action that an approver rejected, whose approval request expired, or that the organization cancelled.No
unavailableNothing was executed right now. retryable says whether the same call may succeed later.No
upstream_errorThe call was executed or attempted, and the tool failed. upstream holds the tool's own structured error, if any.Possibly: check its effect before repeating
replayThe action was already executed (REPLAY) or is being executed by another request (EXECUTION_IN_PROGRESS, DUPLICATE_IN_FLIGHT).Not by this call; an earlier or concurrent request ran it or is running it
invalid_request, plan_limit, unauthorizedThe other refusals of the Gateway; read code and detail.No
unrecognizedA refusal status this client version does not know (gatewayStatus holds it).Unknown; never reported as a success

Every refusal carries code (for example POLICY_DENIED or APPROVAL_PENDING), detail, message, and, when the Gateway sent them, actionId, approvalRequestId, expiresAt, retryable, receipt and policy. The full lists are in Action statuses and Error codes.

A completed result's receiptId is undefined only when the Gateway executed the call but could not read its receipt back while answering. The call did run: do not repeat it, and find the receipt through the action.

Parsing fails closed

The client never reports something as completed without evidence that K-Block recorded it. A success without _meta.kblock.protected_action_id, an error without structuredContent.kblock (status, code, detail), or an approval_pending answer without its action and approval IDs throws KBlockProtocolError. That usually means gatewayUrl points at something other than the K-Block Gateway.

The policy's explanation

When your organization discloses explanations to agents (see What agents are told), a refusal's policy holds the decision as data: policyRef (<name>@<version>), decision, decidedBy (a rule, the policy's default, or a check), conditions, amountBand, and for require_approval the approval roles, approver count and TTL. why is the Gateway's one-sentence explanation. At the default disclosure rule, conditions is empty and amountBand is null.

explainResult(r) turns any result into text an LLM can act on: the tool's output for completed; for approval_pending, that nothing ran yet and how to resume; for denied, the code, detail, the Why: line and the deciding rule. describePolicy(r.policy) gives a short "policy …, rule …, because …" description.

Idempotency keys

By default every callTool sends a fresh idempotency key (kbc-<uuid>) in _meta["kblock/idempotency-key"]. With a key, repeating the same call after it executed returns the stored outcome instead of running the tool again.

idempotencyKey optionWhat is sent
omittedA fresh key per call (turn this off with autoIdempotencyKeys: false on connectKBlock)
a stringYour key: 1–200 characters of well-formed text without U+0000, otherwise RangeError
falseNo key. The Gateway resumes an open action of the identical call by its fingerprint, and runs an identical call again once the first one finished.

A key belongs to one exact call (same tool and arguments) for good. Sending it with different arguments is refused as invalid_request with code IDEMPOTENCY_KEY_REUSED. And once its action ended (executed, denied, rejected, expired or cancelled), the same key keeps returning that outcome: a new attempt needs a new key.

Approvals: wait or poll

When a policy requires approval, the first call returns approval_pending and nothing runs. An approver decides in the dashboard (Approvals, open the request, then Approve and sign; see Review and decide). The agent then repeats the same call (same tool, arguments and idempotency key), and the Gateway executes it once.

Wait with waitForApproval

waitForApproval resumes the pending call with _meta["kblock/wait-seconds"], so the Gateway holds each request open until the decision lands or its wait cap is reached.

approval.ts
import { connectKBlock, explainResult } from "@kblockai/client";

const client = await connectKBlock({
  gatewayUrl: process.env.KBLOCK_GATEWAY_URL ?? "",
  apiKey: process.env.KBLOCK_API_KEY ?? "",
});

const first = await client.callTool("refund", { order_id: "ord_123", amount: "250.00", currency: "USD" });
if (first.status === "approval_pending") {
  console.log(`Approve request ${first.approvalRequestId} in the K-Block dashboard`);
  const done = await client.waitForApproval(first, {
    timeoutSeconds: 600, // give up after 10 minutes (default 300)
    waitSecondsPerRequest: 30, // how long the Gateway may hold each request (default 60)
    onPending: (p) => console.log("still pending, open until", p.expiresAt),
  });
  if (done.status === "completed") console.log("executed once, receipt", done.receiptId);
  else console.log(explainResult(done)); // rejected, expired, cancelled… or still approval_pending
}

await client.close();

How it behaves:

  • It returns the first result that is not approval_pending. When timeoutSeconds passes it returns the last approval_pending result; it does not throw.
  • Rate limits (KBlockRateLimitedError) and a temporarily unavailable Gateway (KBlockUnavailableError other than HTTP_ERROR) are waited out, using Retry-After (2 seconds when absent), only when the call has an idempotency key. Without a key, or when the pause would run past timeoutSeconds, they are thrown.
  • When the Gateway answers within a second (for example because its wait cap is lower than requested), the client pauses up to 2 seconds before the next request; it never spins.
  • signal (an AbortSignal) cancels the wait.

Poll yourself with resume

resume(result, { waitSeconds?, signal?, timeoutMs? }) repeats a call once. A pending call returns approval_pending again, or, once approved, executes and returns completed. A call that already executed returns its stored outcome when it has an idempotency key.

poll.ts
import { connectKBlock, explainResult, type KBlockCallResult } from "@kblockai/client";

const client = await connectKBlock({
  gatewayUrl: process.env.KBLOCK_GATEWAY_URL ?? "",
  apiKey: process.env.KBLOCK_API_KEY ?? "",
});

// Your own key: the same order always maps to the same action.
let r: KBlockCallResult = await client.callTool("refund", { order_id: "ord_123", amount: "250.00", currency: "USD" }, { idempotencyKey: "refund-ord_123" });

while (r.status === "approval_pending") {
  await new Promise((resolve) => setTimeout(resolve, 5_000));
  r = await client.resume(r); // same tool, arguments and key
}
console.log(explainResult(r));

await client.close();

Wait limits

LimitValue
waitSeconds accepted by the clientWhole seconds from 0 to 600 (MAX_WAIT_SECONDS); otherwise RangeError
Gateway cap60 seconds by default (DEFAULT_GATEWAY_MAX_WAIT_SECONDS). Longer requested waits are cut to the cap.
Concurrent waitsAt most 20 per API key, and 1,000 in total on each Gateway. A call over either limit is answered approval_pending at once, without waiting; its detail ends with "(not waited: …)".
Client-side timeout of one callrequestTimeoutMs (or the call's timeoutMs) plus the wait

callTool sends no wait by default (waitSeconds 0), so approval_pending comes back at once. Pass waitSeconds to callTool to let the Gateway hold the first request too.

Receipts

Every executed call has a receipt ID, and so does a recorded action that an approver rejected or the organization cancelled, or whose execution failed. A denied or expired call has none. The receipt itself is a portable .kbundle file that you download from the Cloud API and verify offline with @kblockai/sdk/verify.

Pass receipts to connectKBlock and client.receiptUrls(result) builds the URLs:

receipt-urls.ts
import { connectKBlock, receiptIdOf } from "@kblockai/client";

const client = await connectKBlock({
  gatewayUrl: process.env.KBLOCK_GATEWAY_URL ?? "",
  apiKey: process.env.KBLOCK_API_KEY ?? "",
  receipts: {
    organizationId: process.env.KBLOCK_ORGANIZATION_ID ?? "", // the UUID in the dashboard URL /orgs/<id>/…
    apiUrl: process.env.KBLOCK_CLOUD_API_URL ?? "", // the Cloud API's base URL, from K-Block
  },
});

const r = await client.callTool("lookup_order", { order_id: "ord_123" });
console.log("receipt ID:", receiptIdOf(r));
const urls = client.receiptUrls(r); // undefined when the result has no receipt
console.log(urls?.bundle); // <apiUrl>/api/v1/orgs/<org>/receipts/<receiptId>/bundle

await client.close();
  • bundle and api (the Cloud API's JSON view of the receipt) are set when apiUrl is given, page (the dashboard's receipt page) when dashboardUrl is given. Both IDs must be UUIDs and both base URLs http(s), otherwise TypeError.
  • apiUrl is the Cloud API's base URL for programs, which K-Block gives you, without /api/v1 (see Cloud API → Base URL). The example reads it from KBLOCK_CLOUD_API_URL and the organization ID from KBLOCK_ORGANIZATION_ID, two more environment variables of its own.
  • Without receipts (and without a location argument), receiptUrls throws a TypeError: the gateway API key does not reveal the organization.
  • Downloading the bundle needs a signed-in member of the organization (any role) or a management API key of the organization with the receipts:read scope. The agent's gateway key cannot read receipts.

receiptIdOf(result) and actionIdOf(result) read the receipt and action IDs of any result, refusals included.

Errors

Everything the Gateway decides about a call is a result. These errors are thrown instead, for problems before or around the decision. All of them extend KBlockError, which has a stable code; isKBlockError(e) tells them apart from other errors.

ClasscodeWhenWhat to do
KBlockAuthErrorUNAUTHORIZED (status 401)The Gateway refused the key: unknown, revoked or expired, a management key, or a disabled agent.Use an active gateway key of an active agent. Not retryable with the same key.
KBlockAuthErrorMALFORMED_API_KEY (status undefined)The key is not in the K-Block format. Nothing was sent.Fix the value (quotes, whitespace, truncated copy).
KBlockRateLimitedErrorRATE_LIMITED (status 429)The key was rate-limited. Nothing was processed.Wait retryAfterSeconds (when sent), then retry the same call.
KBlockUnavailableErrorDATABASE_UNAVAILABLE, GATEWAY_SHUTTING_DOWN (status 503)The Gateway is temporarily unavailable: it cannot reach its database, or it is shutting down. Nothing was processed; outcomeUnknown is false.Retry later (retryAfterSeconds when sent). If it continues, contact K-Block support with the code and the time it happened.
KBlockUnavailableErrorHTTP_ERRORAnother 5xx, for example from a proxy, including a 503 without one of the two codes above. outcomeUnknown is true.Repeat only with the same idempotency key.
KBlockUnavailableErrorNETWORK_ERRORThe connection failed. outcomeUnknown is false when it failed before sending (refused, DNS…), true when it broke after sending.Check the Gateway URL and your own network or proxy; read outcomeUnknown before you repeat a tool call. If the connection keeps failing, contact K-Block support with the code and the time it happened.
KBlockUnavailableErrorRPC_UNAVAILABLEA JSON-RPC error saying the Gateway's database is unavailable (for example on tools/list). outcomeUnknown is false.Retry later. If it continues, contact K-Block support with the code and the request ID that the message quotes.
KBlockUnavailableErrorTIMEOUTNo answer within the request timeout. outcomeUnknown is true for a tool call, false for connectKBlock and listTools.For a tool call, repeat only with the same idempotency key.
KBlockHttpErrorHTTP_ERRORHTTP 413 (request over the Gateway's 1 MiB limit), 404 (no MCP endpoint at that URL), a 3xx redirect, or status 0 (a request to another origin refused before sending, or a browser's opaque redirect).Read status; fix the URL or send smaller arguments.
KBlockProtocolErrorNOT_A_KBLOCK_RESULTThe answer is not a K-Block Gateway result. result holds the raw answer.Check that gatewayUrl points at the K-Block Gateway.
KBlockRefusalErrorthe refusal's codeThrown only by the LangChain.js adapter with throwOnRefusal; result holds the refusal. callTool never throws it.Handle it like the refusal result.
KBlockErrorfor example TOO_MANY_PAGESThe base class; thrown directly when tools/list returns more than 1000 pages.Check that gatewayUrl points at the K-Block Gateway; if it does, contact K-Block support with the code.

Invalid options are programming errors: TypeError (bad URL, empty tool name, receiptUrls without an organization) and RangeError (bad waitSeconds, idempotencyKey, requestTimeoutMs or timeoutSeconds).

Other 4xx answers (for example a 400 for a malformed JSON-RPC body) are passed to the MCP SDK unchanged, so they surface as the MCP SDK's own error, not as a KBlockError.

errors.ts
import {
  KBlockAuthError,
  KBlockHttpError,
  KBlockProtocolError,
  KBlockRateLimitedError,
  KBlockUnavailableError,
  connectKBlock,
  isKBlockError,
} from "@kblockai/client";

try {
  const client = await connectKBlock({
    gatewayUrl: process.env.KBLOCK_GATEWAY_URL ?? "",
    apiKey: process.env.KBLOCK_API_KEY ?? "",
  });
  try {
    const r = await client.callTool("lookup_order", { order_id: "ord_123" }, { idempotencyKey: "lookup-ord_123" });
    console.log(r.status);
  } finally {
    await client.close();
  }
} catch (e) {
  if (e instanceof KBlockAuthError) {
    console.error("key refused:", e.code, e.status); // UNAUTHORIZED 401, or MALFORMED_API_KEY undefined
  } else if (e instanceof KBlockRateLimitedError) {
    console.error("rate-limited; retry after", e.retryAfterSeconds ?? "a while", "s");
  } else if (e instanceof KBlockUnavailableError) {
    // outcomeUnknown: the call may have run. Repeat it only with the same idempotency key.
    console.error("unavailable:", e.code, "outcome unknown:", e.outcomeUnknown);
  } else if (e instanceof KBlockHttpError) {
    console.error("HTTP", e.status, e.message);
  } else if (e instanceof KBlockProtocolError) {
    console.error("not a K-Block Gateway answer:", e.message);
  } else if (isKBlockError(e)) {
    console.error(e.code, e.message);
  } else {
    throw e;
  }
}

When outcomeUnknown is true, the request may have reached the Gateway and been processed. Repeat a tool call only with the same idempotency key: the Gateway then returns the stored outcome instead of running it twice. The error's call holds the tool, the arguments and the key the call was sent with: when it has a key (error.call.idempotencyKey), client.resume(error.call) repeats it exactly. A call sent without a key (idempotencyKey: false, or the LangChain tools' default) would run again: check whether it had an effect first. A keyless repeat (idempotencyKey: false) of a call that already finished is a new request.

Decision only: authorizeAction

For a system that is not an MCP server, authorizeAction asks the Gateway to authorize one exact action that your code then performs itself (POST /v1/authorize; no MCP connection). It is a function, not a method of KBlockClient:

authorize.ts
import { authorizeAction } from "@kblockai/client";

const answer = await authorizeAction({
  gatewayUrl: process.env.KBLOCK_GATEWAY_URL ?? "",
  apiKey: process.env.KBLOCK_API_KEY ?? "",
  tool: "deploy_service",
  arguments: { service: "checkout", environment: "staging", commit: "abc1234" },
  idempotencyKey: "deploy-run-4711-staging",
  waitSeconds: 30, // hold the request while a person decides, if the policy asks for one
});
if (answer.allowed) {
  // Deploy now, exactly as authorized, once per answer.protectedActionId.
} else if (answer.status === "approval_pending") {
  // Ask again later with the same tool, arguments and idempotency key.
}
OptionDefaultMeaning
gatewayUrl, apiKeyrequiredAs for connectKBlock. A trailing /mcp in the URL is accepted.
toolrequiredThe exposed name of a decision-only tool.
arguments{}The exact arguments you will use.
idempotencyKeya fresh keyYour key for the business operation; false sends none. The answer's idempotencyKey is the one sent.
waitSecondsnoneHow long the Gateway may wait for a person's decision.
timeoutMs30000Time allowed for the request, on top of waitSeconds. A timeout throws KBlockUnavailableError TIMEOUT with outcomeUnknown: true: ask again with the same key.

The answer has allowed: true only for status: "authorized", with protectedActionId, receiptId, expiresAt and the evidence IDs; any other status has allowed: false, a code, a detail and retryable. An answer that claims an authorization without its evidence throws KBlockProtocolError. See Decision-only integrations for what K-Block enforces in this mode and what it cannot.

Configuration reference

connectKBlock options

OptionDefaultDescription
gatewayUrlrequiredThe Gateway's base URL or its /mcp endpoint (string or URL).
apiKeyrequiredThe agent's gateway API key.
requestTimeoutMs120000Time allowed for one MCP request, on top of any approval wait. Must be greater than 0.
allowInsecureHttpfalseAllow plain http: to a non-loopback host.
autoIdempotencyKeystrueSend a fresh idempotency key with every callTool that passes none.
receiptsnone{ organizationId, apiUrl?, dashboardUrl? } for receiptUrls.
clientInfo{ name: "@kblockai/client", version: "0.1.0-rc.1" }The MCP client name and version.
fetchglobal fetchA custom fetch (proxies, tests). Origin pinning and redirect refusal still apply.

Per-call options

OptionApplies toDescription
idempotencyKeycallToolA string, false, or omitted (see Idempotency keys).
waitSecondscallTool, resumeHow long the Gateway may hold a pending call (0–600; capped by the Gateway).
timeoutMscallTool, resumeOverrides requestTimeoutMs for this call; the wait is added.
signalcallTool, resume, listTools, waitForApprovalAn AbortSignal. The client does not turn an abort into a KBlockError.
timeoutSecondswaitForApprovalGive up after this many seconds (default 300).
waitSecondsPerRequestwaitForApprovalThe wait asked for in each request (default 60).
onPendingwaitForApprovalCalled with every intermediate approval_pending result.

Other exports

ExportPurpose
IDEMPOTENCY_KEY_META, WAIT_SECONDS_METAThe _meta keys kblock/idempotency-key and kblock/wait-seconds, for code that uses client.mcp directly.
MAX_WAIT_SECONDS, DEFAULT_GATEWAY_MAX_WAIT_SECONDS600 and 60.
gatewayEndpoint(url, allowInsecureHttp?)The /mcp URL connectKBlock would use, with the same checks.
receiptUrls(resultOrId, location)The URLs of Receipts without a client, for example in a service that only reads receipts.
parseCallResult(raw, call), parsePolicyExplanation(value)The parsers behind callTool, for raw MCP answers.
GATEWAY_REFUSAL_STATUSES, AGENT_EXPLANATION_SCHEMAThe refusal statuses and the explanation schema ID kblock.cloud/agent-explanation@1.
API_KEY_PATTERN, redactApiKey(key)The key format, and a key with its secret part removed (kb_live_abcdefghijkl_…) for your own logs.

Next steps