Not every system an agent or a job acts on is an MCP server. A REST service, a queue worker, a cron or CI job, or a function in your own process can still be protected by K-Block: register it as a decision-only integration, declare the actions your code performs, and ask the Gateway before each one. K-Block decides with the same policy, delegation, approvals and limits as any protected call, and records signed evidence of the decision. Your code performs the action, and only when the answer allows it.
Gateway-enforced or decision-only
K-Block offers two ways to protect an action. Choose per system.
| Gateway-enforced (MCP server) | Decision-only (your code executes) | |
|---|---|---|
| Who performs the action | The Gateway, after deciding | Your code, after asking |
| How you ask | tools/call on the Gateway's MCP endpoint | POST /v1/authorize (JSON), or authorizeAction / authorize_action in the SDKs |
| Policy, argument conditions, delegation, plan limits | Yes | Yes |
| Approvals by people, emergency stop, idempotency keys | Yes | Yes |
| Can code bypass K-Block? | Not without a credential of the system itself: the Gateway holds it, the agent does not | Yes: K-Block cannot stop code that ignores the answer or calls the system directly |
| Exactly-once execution | Yes: the Gateway executes an authorized call once | No: K-Block authorizes once; your code must perform it at most once |
| The receipt proves | The decision, the execution request and its result (plus attestations of K-Block-aware servers) | The decision only: the signed TASK and PROPOSAL, and the person's APPROVAL when one was required. No execution and no result |
Use the Gateway-enforced mode when the system can be an MCP server and you want K-Block in the execution path. Use decision-only when it cannot, or when the action happens inside your own process: K-Block then gives you the policy decision, the approval workflow and verifiable evidence of what was authorized, and your code stays responsible for honouring the answer.
Set it up
Register the integration
In the dashboard, open Tools & MCP servers, select Register MCP server, and choose Your code (decision only) under Who executes the calls. Give it a name and select Register integration. It has no URL, no credential and no server KID: K-Block never connects to it.
Declare its tools
On the integration's page, under Declare a tool, enter the name of each operation your code performs (for example
issue_refundordeploy_service) and, optionally, the JSON Schema of its arguments. Then set its exposed name, action and amount mapping exactly as for a tool of an MCP server. Owners and admins enable and authorize tools; a developer's tool is saved as a disabled draft.Give an agent a policy, a delegation and a gateway API key
The caller is an agent, even when it is a job and not an AI: it needs a policy, a delegation that grants the tools' actions, and a gateway API key. Write argument conditions to decide on the exact values your code will use.
Ask before each action
Call
POST /v1/authorizewith the tool's exposed name and the exact arguments, then perform the action only whenallowedistrue.
A decision-only tool is never listed by tools/list and never executed by tools/call: a tools/call of it is refused with DECISION_ONLY_TOOL. A tool of an MCP server asked for with POST /v1/authorize is refused with NOT_DECISION_ONLY. Nothing is recorded in either case.
Ask from TypeScript
import { authorizeAction } from "@kblockai/client";
async function refund(orderId: string, amount: string, reason: string): Promise<void> {
const answer = await authorizeAction({
gatewayUrl: process.env.KBLOCK_GATEWAY_URL!,
apiKey: process.env.KBLOCK_API_KEY!,
tool: "issue_refund",
arguments: { order_id: orderId, amount, reason },
// One key per business operation: asking again never creates a second authorization.
idempotencyKey: `refund-${orderId}`,
});
if (!answer.allowed) {
console.log(`not refunded (${answer.status}): ${answer.detail}`);
return;
}
// Perform the action now, exactly as authorized, and record it under answer.protectedActionId.
console.log(`refunding ${orderId}; K-Block authorization ${answer.protectedActionId}`);
}
await refund("ord_1001", "40.00", "damaged");authorizeAction checks the key's format, the URL and the request before sending anything, and throws the same typed errors as the TypeScript client for transport failures (KBlockAuthError, KBlockRateLimitedError, KBlockUnavailableError, KBlockHttpError). Every decision is a result, never an exception.
Ask from Python
import os
from kblock import authorize_action_sync
answer = authorize_action_sync(
os.environ["KBLOCK_GATEWAY_URL"],
os.environ["KBLOCK_API_KEY"],
"rotate_keys",
{"scope": "service-a", "algorithm": "ed25519"},
idempotency_key="rotate-2026-10-14-service-a",
)
if answer.allowed:
print("rotating; K-Block authorization", answer.protected_action_id)
else:
print("not rotated:", answer.status, answer.code)authorize_action is the async form with the same arguments. Both are in the kblock package; see Python.
Ask over HTTP
Any language can ask with one JSON request. Send the agent's gateway API key as a bearer token:
POST /v1/authorize HTTP/1.1
Host: gateway.example.com
Authorization: Bearer kb_live_…
Content-Type: application/json
{"tool": "deploy_service", "arguments": {"service": "checkout", "environment": "staging", "commit": "abc1234"}, "idempotency_key": "deploy-run-4711-staging"}| Member | Required | Value |
|---|---|---|
tool | Yes | The exposed name of a decision-only tool (1 to 128 characters). |
arguments | No | The exact arguments your code will use: a JSON object (default {}). The limits of tool-call arguments apply: whole numbers only (send decimals as strings), at most 224 KiB as canonical JSON. |
idempotency_key | No | 1 to 200 characters. The same key returns the same answer instead of a new decision. |
wait_seconds | No | How long the Gateway may hold the request while a person decides (whole seconds, capped by the Gateway). |
Any other member is refused with 400 INVALID_REQUEST. An authorized answer:
{
"allowed": true,
"status": "authorized",
"decision": "allow",
"execution": "caller",
"protected_action_id": "4d0f7c2e-8a51-4c3b-9f8e-1b2a3c4d5e6f",
"approval_request_id": null,
"expires_at": "2026-10-14T13:00:00.000Z",
"task_object_id": "obj_…",
"proposal_object_id": "obj_…",
"arguments_digest": "sha256:…",
"receipt_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"receipt_object_id": "obj_…",
"policy": null,
"note": "Decision only: K-Block authorizes (or refuses) this exact call and records the decision; it does not execute it. Execute only an allowed call, exactly as authorized, before expires_at."
}Every other decision has "allowed": false, a status (approval_pending, denied, rejected, expired, cancelled, plan_limit, invalid_request, unavailable), a code and a detail. The HTTP status is 200 for decisions, 400 for an invalid request, 401 for a key that cannot act, and 503 when the Gateway cannot decide now (unavailable, retryable: true, nothing recorded). See Error codes.
Approvals
When the policy requires an approval, the first answer is approval_pending with an approval_request_id. A person decides in the dashboard as for any call. To get the result, ask again with the same tool, arguments and idempotency key:
- with
wait_seconds, the Gateway holds the request until the person decides (or the wait ends); - without it, the answer comes at once, still
approval_pendinguntil someone decides.
Once approved, the next request is answered authorized with a receipt over the person's signed APPROVAL. A rejection is answered rejected.
Perform each authorization at most once
K-Block authorizes a call once. Asking again with the same idempotency key, after it was authorized and before its expires_at, returns the same authorization (same protected_action_id, same receipt): that is how a caller whose answer was lost gets it back. It also means K-Block cannot tell your code whether it already performed the action.
After expires_at, the same request answers "allowed": false with status expired and code AUTHORIZATION_EXPIRED, still with the protected_action_id and the receipt's IDs. The call was authorized, and its receipt still proves that; but an authorization whose window has ended is not permission to perform the action now. If it is still needed, ask again without that idempotency key (or with a new one) for a new decision.
- Record what your code performed under
protected_action_id, and skip an authorization you already performed. - Use one idempotency key per business operation (an order, a pipeline run and target, a night and scope), not a fresh key per attempt. A request without a key that repeats a finished call is a new decision.
- Perform the action before
expires_at, with exactly the authorized arguments: the receipt binds them (arguments_digest).
What is checked, and when
The decision is the same as for a Gateway-enforced call: the agent's policy (with argument conditions), its delegation, the plan's monthly limit, the tool's authorized binding and the idempotency key. When the Gateway hands out the authorization, it checks again under the same locks as an execution: the API key, the agent, the tool, the delegation, the policy in force, and an emergency stop. An approved action that a stop closes, or whose delegation was revoked meanwhile, is never authorized.
Each new decision counts as one protected action for the plan, like a Gateway-enforced call; asking again with the same idempotency key does not. The webhook event action.authorized reports each authorization; see Webhook events.
What the receipt proves
The receipt of an authorization is an ordinary K-Block receipt, verified with the same tools (Verify a receipt). Its path ends at:
- the PROPOSAL, when the policy allowed the call (receipt outcome
pending): no approval was required; - the person's APPROVAL, when one was required (outcome
granted).
Both the TASK and the PROPOSAL, signed by the Gateway, say that the call is decision only and that the caller executes it, and the PROPOSAL names the integration with "execution": "caller". Changing any argument, the decision, an actor, the delegation or the approval breaks the signatures for every verifier. The receipt contains no EXECUTION and no RESULT, because K-Block performed nothing.
It is an authorization receipt: evidence that K-Block authorized the call, never that it executed it, and never that your code performed it (or refrained from it after a refusal). Its outcome, pending or granted, is the protocol state of the PROPOSAL or APPROVAL at its end, not a request still waiting. The dashboard shows such an action as Authorized (decision only) and its receipt as an authorization receipt; the Cloud API reports the action with "execution": "caller" and the receipt with "kind": "authorization", and the receipt.created webhook event carries the same kind. The text report of the command-line verifier prints the outcome as signed (pending or granted).
A Gateway-enforced call gives stronger evidence: there, K-Block itself performs the authorized call once, and its receipt also covers the execution request and the result.