Docs

Decision-only integrations

Protect systems that are not MCP servers: your code asks K-Block to authorize an exact action, then performs it itself. What K-Block enforces in this mode, what it cannot, and how its receipts differ from Gateway-enforced calls.

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 actionThe Gateway, after decidingYour code, after asking
How you asktools/call on the Gateway's MCP endpointPOST /v1/authorize (JSON), or authorizeAction / authorize_action in the SDKs
Policy, argument conditions, delegation, plan limitsYesYes
Approvals by people, emergency stop, idempotency keysYesYes
Can code bypass K-Block?Not without a credential of the system itself: the Gateway holds it, the agent does notYes: K-Block cannot stop code that ignores the answer or calls the system directly
Exactly-once executionYes: the Gateway executes an authorized call onceNo: K-Block authorizes once; your code must perform it at most once
The receipt provesThe 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

  1. 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.

  2. Declare its tools

    On the integration's page, under Declare a tool, enter the name of each operation your code performs (for example issue_refund or deploy_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.

  3. 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.

  4. Ask before each action

    Call POST /v1/authorize with the tool's exposed name and the exact arguments, then perform the action only when allowed is true.

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

refund.ts
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

rotate_keys.py
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
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"}
MemberRequiredValue
toolYesThe exposed name of a decision-only tool (1 to 128 characters).
argumentsNoThe 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_keyNo1 to 200 characters. The same key returns the same answer instead of a new decision.
wait_secondsNoHow 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:

200 OK
{
  "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_pending until 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.

Next steps