@kblockai/client
v0.1.0-rc.1
Published
Connect an existing agent to the K-Block Gateway: an MCP client over Streamable HTTP with typed K-Block outcomes (completed, approval pending, denied with the policy's explanation…), approval waiting and resumption, typed errors, and an optional LangChain
Maintainers
Readme
@kblockai/client
Protect an existing agent with K-Block by pointing its tool traffic at the K-Block Gateway and adding an API key. The Gateway is an MCP server (Streamable HTTP) in front of your organization's MCP servers. It applies your organization's policy, holds calls for human approval, executes approved calls exactly once and records a verifiable receipt for each. This package is a thin client for it: it decides nothing itself, speaks the Gateway's MCP conventions and gives you typed outcomes.
Pre-release (0.1.0-rc.1) for invited pilot users. You need a K-Block Gateway URL and an agent's
gateway API key (kb_live_… / kb_test_…, shown once when it is created in the K-Block dashboard).
K-Block gives you both.
Requirements
- Node.js 22 or later (tested on Node.js 22.17).
- ESM only:
"type": "module"in yourpackage.json, or.mjsfiles. - Runtime dependency:
@modelcontextprotocol/sdk(^1.30.1). LangChain.js support is optional: install@langchain/core1.x next to this package to use@kblockai/client/langchain. - TypeScript (optional):
module/moduleResolutionnodenext(orbundlerwith a bundler),types: ["node"], and theDOMlib orskipLibCheck: true(the MCP SDK's declarations useHeadersInit). Declarations are included.
Install
npm install --save-exact @kblockai/[email protected]With LangChain.js:
npm install --save-exact @kblockai/[email protected] @langchain/core@^1A first program
The code on this page is taken unchanged from complete example programs (quickstart.mjs,
agent.ts, langchain_agent.ts, verify_receipt.mjs). quickstart.mjs makes three calls: one
the policy allows, one that needs a person's approval, one the policy denies.
import { randomUUID } from "node:crypto";
import { connectKBlock, explainResult } from "@kblockai/client";
const gatewayUrl = process.env.KBLOCK_GATEWAY_URL;
const apiKey = process.env.KBLOCK_API_KEY;
if (!gatewayUrl || !apiKey) {
console.error("Set KBLOCK_GATEWAY_URL and KBLOCK_API_KEY to the Gateway URL and the agent's gateway API key that K-Block gave you.");
process.exit(2);
}
const client = await connectKBlock({ gatewayUrl, apiKey });
// 1. Allowed: the Gateway executes the call exactly once and records a receipt.
const r = await client.callTool("lookup_order", { order_id: `ord_${randomUUID()}` });
if (r.status === "completed") console.log("1. completed:", r.structuredContent, "receipt", r.receiptId, `(${r.receiptObjectId})`);
else console.log("1.", explainResult(r));
// 2. Approval required: nothing runs until a person approves the call in the K-Block dashboard;
// waitForApproval repeats the same call until the decision lands, and it then executes once.
const pending = await client.callTool("refund", { order_id: `ord_${randomUUID()}`, amount: "250.00", currency: "USD" });
console.log("2.", explainResult(pending));
if (pending.status === "approval_pending") {
console.log(` waiting for a person to approve request ${pending.approvalRequestId} in the K-Block dashboard`);
const done = await client.waitForApproval(pending, { timeoutSeconds: 600, waitSecondsPerRequest: 30 });
if (done.status === "completed") console.log(" completed:", done.structuredContent, "receipt", done.receiptId, `(${done.receiptObjectId})`);
else console.log(" ", explainResult(done));
}
// 3. Denied: the policy's explanation; nothing was executed.
const denied = await client.callTool("refund", { order_id: `ord_${randomUUID()}`, amount: "2500.00", currency: "USD" });
console.log("3.", explainResult(denied));
await client.close();KBLOCK_GATEWAY_URL="<Gateway URL>" KBLOCK_API_KEY="<gateway API key>" node quickstart.mjsThe client reads no environment variable itself: your application passes the Gateway URL and the
key to connectKBlock, from wherever it keeps its configuration. The program assumes an
organization whose tools lookup_order (action orders.read) and refund (action refund.issue,
with its amount and currency) are under a policy made from the Support refunds template
(support-refunds@1, default limits); with other tools, use their names, arguments and amounts. Amounts are decimal strings ("250.00"): the Gateway refuses
non-integer JSON numbers, because it signs the exact arguments into the evidence.
Outcomes
callTool(name, args, { idempotencyKey?, waitSeconds?, signal?, timeoutMs? }) returns a
KBlockCallResult, discriminated by status:
| status | Meaning | Useful members |
|---|---|---|
| completed | executed; the upstream's answer | content, structuredContent, actionId, receiptId, observe? |
| approval_pending | a person must approve the exact call; nothing ran | actionId, approvalRequestId, expiresAt, policy? |
| denied | refused by the policy or a check; nothing ran | code, detail, policy?, why? |
| rejected / expired / cancelled | a person rejected it, it expired, or it was cancelled | code, receipt? |
| unavailable | nothing ran right now | code, retryable |
| upstream_error | the upstream failed (it may have acted: see detail) | code, receipt?, upstream? |
| invalid_request / plan_limit / replay / unauthorized | other refusals | code, detail |
| unrecognized | a refusal status this version does not know (never a success) | gatewayStatus |
In the policy's terms: a call the policy allows is executed (completed); a call that
requires approval is approval_pending until a person decides (then completed, rejected or
expired); a call the policy denies is denied with the code POLICY_DENIED. denied also
covers checks K-Block makes before the policy, each with its own code: for example INVALID_AMOUNT
when the call's amount cannot be read or, for a tool that reads the call's currency, names a
currency other than the tool's, or NO_APPROVERS when approval is required but no member who may
approve has a personal K-Block key. A completed result does not say whether a person approved the
call first; its receipt does. observe is present when the call was executed under an observe
window, which relaxes the policy's default deny (and, if the owner chose so, an approval rule
without a guard) while a new policy is observed.
Every result carries call (tool, arguments, idempotency key) and raw (the Gateway's answer).
policy is the explanation the organization discloses to agents: policyRef (name@version),
decidedBy (the rule), conditions with thresholds (e.g. amount 2500 USD > 1000 USD) and amountBand
only when the organization opts in to the conditions disclosure (the default rule sends neither;
at none, policy and why are both absent),
approval (roles, approver count, TTL) and a one-line message. It never contains user IDs,
delegation data or argument values other than the amount. An explanation with a value this client
version does not know is not interpreted: policy is then absent and why still carries the
Gateway's own sentence.
explainResult(result) turns any outcome into text an LLM can act on. For approval_pending it says
how to resume (see below).
Fail closed. A success without K-Block evidence (_meta.kblock with the protected action ID)
or an error without a K-Block refusal is not reported as an outcome: the client throws
KBlockProtocolError. That happens when gatewayUrl points at something other than the Gateway. A
completed call's receiptId is undefined only when the Gateway executed the call but could not
read its receipt back while answering: the call ran (do not repeat it).
Approvals: waiting and resuming
A pending call is resumed by repeating the same call: same tool, same arguments, same
idempotency key. callTool sends a fresh idempotency key by default (kbc-<uuid>, in
result.call.idempotencyKey), so:
client.resume(result)repeats it: still pending, or (once approved) executed andcompleted;client.waitForApproval(pending, { timeoutSeconds, waitSecondsPerRequest })resumes with the Gateway'skblock/wait-seconds, so the Gateway holds each request until the decision lands (up to the Gateway's own cap per request), and returns the first non-pending outcome (or the pending one aftertimeoutSeconds);- repeating a call that already executed returns its stored outcome: it never runs twice;
- a plain new
callToolsends a fresh key: that is a new request, which needs its own approval.
Approvers decide in the K-Block dashboard (Approvals), where they see the exact signed call. The client cannot approve.
idempotencyKey: false sends no key: the Gateway then resumes an open action of the identical call
by its fingerprint, but runs an identical call again once the first one finished. Pass your own key
(1–200 characters) to tie retries across processes. waitSeconds on callTool holds the first
request, too.
Errors
Refusals are results; transport failures throw typed errors whose messages say what to do (the API
key's secret part is never included, only its public prefix kb_live_abcdefghijkl_…).
KBlockError and isKBlockError are this package's base class and guard.
| Error | When |
|---|---|
| KBlockAuthError (UNAUTHORIZED, status 401) | the Gateway refused the key: not a gateway key, revoked, expired, or its agent is disabled |
| KBlockAuthError (MALFORMED_API_KEY) | the key is not in the kb_<live\|test>_<prefix>_<secret> format (nothing sent) |
| KBlockRateLimitedError (retryAfterSeconds) | HTTP 429: the per-key rate limit |
| KBlockUnavailableError (code, retryAfterSeconds, outcomeUnknown) | HTTP 503 DATABASE_UNAVAILABLE / GATEWAY_SHUTTING_DOWN (the Gateway's own answers: nothing processed), RPC_UNAVAILABLE (a JSON-RPC answer that the Gateway's database is unavailable, e.g. for listTools), HTTP_ERROR for any other 5xx (outcome unknown), NETWORK_ERROR for a connection failure, TIMEOUT when no answer came in time (outcome unknown for a tool call). outcomeUnknown: true means the request may have been processed: repeat a call only with the same idempotency key. The error's call holds the call as it was sent: when it has a key (error.call.idempotencyKey), client.resume(error.call) repeats it safely; a call sent without a key would run again, so check its effect first |
| KBlockHttpError | e.g. 413 (request over 1 MiB), 404 (wrong URL), a 3xx redirect (never followed: the key goes only to the gatewayUrl's origin) |
| KBlockProtocolError | not a K-Block answer (see "Fail closed"); not a receipt verification error, which is @kblockai/sdk's ProtocolError |
waitForApproval waits out rate limits and temporary unavailability only when the call has an
idempotency key, so a repeat can never run it twice.
gatewayUrl must be https:, or http: to a loopback host; allowInsecureHttp: true allows plain
http elsewhere (the key would travel in clear text). Otherwise connectKBlock throws a TypeError.
Receipts
Each executed call has a receipt: a portable .kbundle that anyone can verify offline. The Gateway
returns the receipt's ID with each completed call (receiptId). A member of the organization
downloads the bundle in the K-Block dashboard (the receipt's page, Download .kbundle), or a
program downloads it from the K-Block Cloud API with a management API key that has the
receipts:read scope (Authorization: Bearer <management API key>). The agent's gateway API key
cannot download receipts. Rejected and cancelled calls also end with a receipt; a call denied when
it was decided, and an approval request that expired, end without one. In a receipt of your
Gateway, the request, the execution and the result are signed by the Gateway on behalf of your
organization, and an approval by the approver's own key; the agent signs nothing.
Given your organization's ID and the Cloud API's URL, the client builds the receipt URLs:
const client = await connectKBlock({
gatewayUrl, // e.g. https://gateway.example.com; locally http://127.0.0.1:<port>
apiKey, // the agent's gateway API key (kb_live_… / kb_test_…)
receipts: { organizationId, apiUrl }, // optional: lets client.receiptUrls() build receipt URLs
});const urls = client.receiptUrls(r); // urls.bundle: <apiUrl>/api/v1/orgs/<org>/receipts/<receiptId>/bundleWith dashboardUrl in receipts, urls.page is the dashboard's receipt page.
To verify a bundle, install the separate package @kblockai/sdk
(npm install --save-exact @kblockai/[email protected]), which works offline and does not trust
K-Block:
import { renderText, verifyBundleJson } from "@kblockai/sdk/verify";const report = verifyBundleJson(await readFile(file, "utf8")); // never throws on invalid input
console.log(renderText(report)); // the seven checks, then RESULT: VALID or RESULT: INVALIDCompare report.issuer with your Gateway's KID (K-Block gives it to you): a valid bundle signed by
another identity is still valid, but not your Gateway's.
LangChain.js
import { loadKBlockTools } from "@kblockai/client/langchain";const tools = await loadKBlockTools(client); // pass `tools` to your agentEach tool returns text the model can reason about (the upstream's content when completed; for a
pending approval, that nothing ran and that the same call must be repeated after approval; for a
denial, the code, detail and the policy's explanation) and the typed KBlockCallResult as the
ToolMessage artifact. When the tool is invoked with a tool call (as agents do), every refusal (held
for approval, denied, rejected, expired, unavailable, an upstream that failed…) is a ToolMessage
with status: "error", so LangGraph edges, evaluations and tracing never count it as a successful
tool run; refusalsAsErrors: false restores status: "success", and throwOnRefusal: true throws
KBlockRefusalError instead. By default no idempotency key is sent, so when the model repeats an
identical call after approval the Gateway resumes it; pass idempotencyKey: (tool, args) => … to
derive one.
Everything else is MCP
client.mcp is the connected MCP Client (Streamable HTTP, Authorization: Bearer <key>), for
frameworks that take one. client.listTools() lists what the agent may call (the Gateway lists only
the tools the agent's delegation grants). client.close() closes the connection.
Versioning
0.1.0-rc.1 is a pre-release. Package versions follow semver and are independent of the K-Block protocol version (v0.1).
License
K-Block SDK License (proprietary); see the LICENSE file in this package.
