@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| Requirement | Value |
|---|---|
| Node.js | 22 or later (engines.node is >=22) |
| Module format | ESM 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_…orkb_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.
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>throwsKBlockAuthErrorwith codeMALFORMED_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:orhttps:, without credentials, query or fragment. You can pass the base URL or the/mcpendpoint; the client appends/mcpwhen it is missing. Plainhttp:is allowed only tolocalhost,127.x.x.xand[::1]; anything else throws aTypeErrorunless you setallowInsecureHttp: 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 throwsKBlockHttpErrorwithstatus0 before it is sent. - Redirects are never followed. A 3xx answer throws
KBlockHttpError; pointgatewayUrlat 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).
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
status | Meaning | Did the tool run? |
|---|---|---|
| completed | The Gateway executed the call. content / structuredContent are the tool's output; actionId and receiptId identify the recorded action and its receipt. | Yes |
| approval_pending | A person must approve this exact call first. approvalRequestId and actionId are always present. | No |
| denied | The policy, or a check outside the policy, refused the call. | No |
| rejected, expired, cancelled | A recorded action that an approver rejected, whose approval request expired, or that the organization cancelled. | No |
| unavailable | Nothing was executed right now. retryable says whether the same call may succeed later. | No |
| upstream_error | The 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 |
| replay | The 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, unauthorized | The other refusals of the Gateway; read code and detail. | No |
| unrecognized | A 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 option | What is sent |
|---|---|
| omitted | A fresh key per call (turn this off with autoIdempotencyKeys: false on connectKBlock) |
| a string | Your key: 1–200 characters of well-formed text without U+0000, otherwise RangeError |
false | No 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.
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. WhentimeoutSecondspasses it returns the lastapproval_pendingresult; it does not throw. - Rate limits (
KBlockRateLimitedError) and a temporarily unavailable Gateway (KBlockUnavailableErrorother thanHTTP_ERROR) are waited out, usingRetry-After(2 seconds when absent), only when the call has an idempotency key. Without a key, or when the pause would run pasttimeoutSeconds, 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(anAbortSignal) 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.
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
| Limit | Value |
|---|---|
waitSeconds accepted by the client | Whole seconds from 0 to 600 (MAX_WAIT_SECONDS); otherwise RangeError |
| Gateway cap | 60 seconds by default (DEFAULT_GATEWAY_MAX_WAIT_SECONDS). Longer requested waits are cut to the cap. |
| Concurrent waits | At 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 call | requestTimeoutMs (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:
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();bundleandapi(the Cloud API's JSON view of the receipt) are set whenapiUrlis given,page(the dashboard's receipt page) whendashboardUrlis given. Both IDs must be UUIDs and both base URLshttp(s), otherwiseTypeError.apiUrlis 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 fromKBLOCK_CLOUD_API_URLand the organization ID fromKBLOCK_ORGANIZATION_ID, two more environment variables of its own.- Without
receipts(and without alocationargument),receiptUrlsthrows aTypeError: 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:readscope. 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.
| Class | code | When | What to do |
|---|---|---|---|
KBlockAuthError | UNAUTHORIZED (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. |
KBlockAuthError | MALFORMED_API_KEY (status undefined) | The key is not in the K-Block format. Nothing was sent. | Fix the value (quotes, whitespace, truncated copy). |
KBlockRateLimitedError | RATE_LIMITED (status 429) | The key was rate-limited. Nothing was processed. | Wait retryAfterSeconds (when sent), then retry the same call. |
KBlockUnavailableError | DATABASE_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. |
KBlockUnavailableError | HTTP_ERROR | Another 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. |
KBlockUnavailableError | NETWORK_ERROR | The 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. |
KBlockUnavailableError | RPC_UNAVAILABLE | A 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. |
KBlockUnavailableError | TIMEOUT | No 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. |
KBlockHttpError | HTTP_ERROR | HTTP 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. |
KBlockProtocolError | NOT_A_KBLOCK_RESULT | The answer is not a K-Block Gateway result. result holds the raw answer. | Check that gatewayUrl points at the K-Block Gateway. |
KBlockRefusalError | the refusal's code | Thrown only by the LangChain.js adapter with throwOnRefusal; result holds the refusal. callTool never throws it. | Handle it like the refusal result. |
KBlockError | for example TOO_MANY_PAGES | The 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.
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:
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.
}| Option | Default | Meaning |
|---|---|---|
gatewayUrl, apiKey | required | As for connectKBlock. A trailing /mcp in the URL is accepted. |
tool | required | The exposed name of a decision-only tool. |
arguments | {} | The exact arguments you will use. |
idempotencyKey | a fresh key | Your key for the business operation; false sends none. The answer's idempotencyKey is the one sent. |
waitSeconds | none | How long the Gateway may wait for a person's decision. |
timeoutMs | 30000 | Time 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
| Option | Default | Description |
|---|---|---|
gatewayUrl | required | The Gateway's base URL or its /mcp endpoint (string or URL). |
apiKey | required | The agent's gateway API key. |
requestTimeoutMs | 120000 | Time allowed for one MCP request, on top of any approval wait. Must be greater than 0. |
allowInsecureHttp | false | Allow plain http: to a non-loopback host. |
autoIdempotencyKeys | true | Send a fresh idempotency key with every callTool that passes none. |
receipts | none | { organizationId, apiUrl?, dashboardUrl? } for receiptUrls. |
clientInfo | { name: "@kblockai/client", version: "0.1.0-rc.1" } | The MCP client name and version. |
fetch | global fetch | A custom fetch (proxies, tests). Origin pinning and redirect refusal still apply. |
Per-call options
| Option | Applies to | Description |
|---|---|---|
idempotencyKey | callTool | A string, false, or omitted (see Idempotency keys). |
waitSeconds | callTool, resume | How long the Gateway may hold a pending call (0–600; capped by the Gateway). |
timeoutMs | callTool, resume | Overrides requestTimeoutMs for this call; the wait is added. |
signal | callTool, resume, listTools, waitForApproval | An AbortSignal. The client does not turn an abort into a KBlockError. |
timeoutSeconds | waitForApproval | Give up after this many seconds (default 300). |
waitSecondsPerRequest | waitForApproval | The wait asked for in each request (default 60). |
onPending | waitForApproval | Called with every intermediate approval_pending result. |
Other exports
| Export | Purpose |
|---|---|
IDEMPOTENCY_KEY_META, WAIT_SECONDS_META | The _meta keys kblock/idempotency-key and kblock/wait-seconds, for code that uses client.mcp directly. |
MAX_WAIT_SECONDS, DEFAULT_GATEWAY_MAX_WAIT_SECONDS | 600 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_SCHEMA | The 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. |