Docs

Execution and results

Who runs an allowed or approved tool call, how K-Block keeps it to one execution, and what the agent receives when the call succeeds, fails or ends in doubt.

Execution is the moment K-Block actually calls the upstream MCP server for a protected action. Only the Gateway does this, and only for an action that is allowed by policy or approved by a person. An execution normally ends with a signed RESULT and a receipt, whether the upstream succeeded or not; the exceptions are in When the outcome is unknown.

Read this page to understand what "exactly once" means in K-Block, what happens when an upstream is slow or unreachable, and how to repeat a call without running it twice.

Who executes the call

The Gateway executes calls; the dashboard and the Cloud API never do. An execution starts in one of these ways:

SituationWhen the Gateway executes
The policy allows the callAt once, in the same tools/call request
The call needed approval and was approvedWhen the agent repeats the identical call
The agent was waiting with _meta["kblock/wait-seconds"] when the approval landedIn the waiting request, as soon as the decision arrives

Approving a call in the dashboard does not run it. If the agent never repeats the call before the deadline, the action expires with TASK_EXPIRED and nothing runs. See Approvals.

"The identical call" means the same tool and the same arguments from the same agent. With an idempotency key, the repeat must also carry the same key. Without a key, the Gateway resumes the open action of that exact call.

From approval to execution

  1. Checks that consume nothing

    The Gateway reloads the tool as it is configured now and compares it with what was decided: the upstream URL and server, the upstream tool name, the action, and the tool's authorized binding. It resolves and checks the upstream address, decrypts the stored credential, and verifies the signed thread. If anything fails, the call is refused with a detail that says nothing was executed and the approval was not consumed. The action stays approved and can still run later if the problem is fixed in time, unless the tool's binding changed (see Approvals).

  2. Consumption

    In a single all-or-nothing step the Gateway re-checks the caller's API key and agent, the tool and its server, the delegation and the organization principal. For a call the policy allowed, it also checks that the policy in force still allows it (otherwise POLICY_CHANGED or OBSERVE_ENDED, nothing consumed). It then moves the action from approved to executing and records the execution. From this point the approval is used up.

  3. The upstream call

    The Gateway sends the tools/call to the upstream MCP server once, over Streamable HTTP, with the stored Authorization header. A K-Block-aware upstream (one registered with a server KID) also receives the evidence in _meta.kblock and must return an attestation.

  4. Recording the outcome

    The Gateway signs a RESULT with its service identity, issues the receipt, stores both, and moves the action to succeeded or failed. Then it answers the agent.

Exactly once

K-Block's "exactly once" is a guarantee about consumption, not a promise about the upstream's real-world effect:

ScopeGuarantee
One protected action, one approval requestAt most one execution. Concurrent repeats execute once, even when they arrive over different connections
One idempotency key (per agent)The first call's outcome is stored; repeats with the same key and the same request return it
Keyless identical callsMerged only while the first action is still open (pending_approval or approved). After it finished, a keyless repeat is a new protected action with a new decision
The upstream callSent at most once per consumption. The Gateway never retries an upstream call

Requests that lose a race for the same action are answered replay / EXECUTION_IN_PROGRESS while the winner runs, or replay / REPLAY after it finished. Of two identical new calls that arrive at almost the same moment, the one that loses can also be answered replay / DUPLICATE_IN_FLIGHT when the other one is already executing; nothing was recorded for it.

Upstream timeouts

The Gateway gives the whole upstream exchange (MCP initialization plus tools/call) one deadline: 30 seconds by default. The upstream's response may be at most 1 MiB. If your tools regularly need longer than the deadline, contact K-Block support.

When the deadline passes, the execution is recorded as failed with the code UPSTREAM_TIMEOUT, and the agent receives upstream_error / UPSTREAM_TIMEOUT with a receipt. If the tools/call had already been sent, the detail says so: the request was sent but no valid answer was received, so the upstream may have acted. Check the upstream's effect before you repeat the call.

The agent's own client has a separate timeout. @kblockai/client allows 120,000 ms per request by default, plus any approval wait you ask for. Keep it longer than the Gateway's upstream timeout, so the Gateway's recorded answer reaches the agent.

What the agent receives

OutcomeAgent receivesReceipt
The upstream returned a resultThe upstream's content and structuredContent, plus _meta.kblockYes
The upstream tool returned an MCP error result (isError)upstream_error / UPSTREAM_TOOL_ERROR, with the upstream's content appended and its structured content under structuredContent.upstreamYes
A K-Block-aware upstream refused without attestingupstream_error / UPSTREAM_REFUSEDYes
The upstream could not be reached, timed out, or answered badlyupstream_error with UPSTREAM_UNREACHABLE, UPSTREAM_TIMEOUT, UPSTREAM_HTTP_ERROR, UPSTREAM_PROTOCOL_ERROR, UPSTREAM_RESPONSE_TOO_LARGE, UPSTREAM_BLOCKED (the address check when connecting) or UPSTREAM_ERRORYes
The upstream answered, but its result cannot be stored or bound into the evidenceupstream_error / OUTPUT_NOT_STORABLE or OUTPUT_NOT_CANONICALYes
A K-Block-aware upstream's attestation is missing, does not verify, or cannot be storedupstream_error / ATTESTATION_MISSING, ATTESTATION_INVALID or ATTESTATION_NOT_STORABLEYes
A check before consumption failedA refusal such as denied / UPSTREAM_CHANGED, or upstream_error / UPSTREAM_BLOCKED — nothing consumedNo

On a recorded outcome, _meta.kblock carries protected_action_id, receipt_id, receipt_object_id and result_object_id. A successful call made under an observe window also carries _meta.kblock.policy. The full list of codes is in Error codes.

A repeat with the same idempotency key returns the stored outcome exactly as the first call did; the upstream is not called again. The repeat is still authenticated and resolved first: if the tool was disabled meanwhile, it is answered invalid_request / UNKNOWN_TOOL instead, and a revoked key or a disabled agent is refused.

When the outcome is unknown

Some failures leave it open whether the upstream acted. K-Block does not hide that, and it has no separate "unknown" status: these cases end as failed, with wording that says the outcome is unknown.

CaseWhat the agent receivesWhat happened
K-Block became temporarily unavailable while the approval was being consumedunavailable / DATABASE_UNAVAILABLE, retryable: falseThe upstream was not called. Whether the approval was consumed is unknown. If it was, recovery later marks the execution failed and issues a receipt
The upstream call ended (with or without an answer), but its outcome could not be recorded at allupstream_error / RESULT_NOT_RECORDED, without a receiptThe action stays executing until recovery marks it failed. Do not repeat the call without checking its effect
The upstream answered, but its result could not be stored as it isupstream_error / RESULT_NOT_STORABLEA failure is recorded instead of the result. The upstream may have succeeded
The Gateway failed while calling the upstream or processing its answerupstream_error / GATEWAY_ERRORThe upstream may have acted
The Gateway stopped during the executionA transport error or timeout in the clientRecovery marks the execution failed (INTERRUPTED)
The connection between agent and Gateway broke after the request was sentThe client reports a transport error with "outcome unknown"The Gateway may have executed and recorded the call

In @kblockai/client, the last case is a KBlockUnavailableError whose outcomeUnknown is true (code NETWORK_ERROR, TIMEOUT or HTTP_ERROR). Repeat the call only with the same idempotency key: the Gateway then returns the stored outcome instead of running it again. The error's call holds that key, so client.resume(error.call) repeats the call exactly. A call sent without a key cannot be repeated safely this way: check whether it had an effect first.

The DATABASE_UNAVAILABLE refusal with retryable: false carries the action's protected_action_id in structuredContent.kblock. Check that action under Actions in the dashboard before you repeat the call. If it is still approved, the approval was not consumed and the identical call can still run it before the deadline. If it is executing, or failed with INTERRUPTED, the approval was consumed and the call needs a new approval. (A DATABASE_UNAVAILABLE refusal with retryable: true means that this request executed nothing and consumed nothing; repeat the same call later.) If DATABASE_UNAVAILABLE keeps occurring, contact K-Block support and give the error code, the protected_action_id if there is one, and the time of the call.

Recovery of interrupted executions

An execution can be left executing if the Gateway stops during it, or if its outcome could not be recorded. K-Block finds such executions and closes them automatically; you do not need to do anything. This normally happens between about 11 and 15 minutes after the execution started. K-Block waits that long so that a call that is still running is never touched.

A recovered execution ends failed with the code INTERRUPTED and the detail "interrupted; upstream outcome unknown", or "interrupted before the upstream was called; the upstream was not called" when the Gateway had noted that it never sent the call. When the action's evidence still verifies, K-Block signs a failed RESULT and issues a receipt; otherwise the execution is marked failed without a receipt.

Failures after approval

Once the approval is consumed, it stays consumed whatever the upstream does. If the upstream call fails, the action ends failed, the RESULT records the failure, and a receipt is issued. The agent receives upstream_error with the precise code. To try again, the agent makes the call again with a new idempotency key (or none), which creates a new protected action that needs a new decision, and a new approval if the policy requires one. A repeat with the old key returns the recorded failure.

Every failed execution of this kind is recorded with the action error code UPSTREAM_FAILED, and the Cloud API reports that code. When the Gateway detected the failure itself, its precise code (for example UPSTREAM_TIMEOUT or RESULT_NOT_STORABLE) is the prefix of the recorded error detail, and the dashboard's action page names that code ("recorded as UPSTREAM_FAILED"). When the upstream tool returned an error result, the detail is the upstream's own error text. Interrupted executions have the code INTERRUPTED.

Repeating a call safely

This example gives one refund its own idempotency key, waits for an approval if one is needed, and repeats the call with the same key when the outcome is unknown. It reads the Gateway URL and the agent's gateway API key from KBLOCK_GATEWAY_URL and KBLOCK_API_KEY, two environment variables of its own.

refund.ts
import { randomUUID } from "node:crypto";
import { KBlockUnavailableError, connectKBlock, explainResult } from "@kblockai/client";

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

// One key for this one refund; every repeat of it uses the same key.
const key = `refund-${randomUUID()}`;
const args = { order_id: "ord_example", amount: "250.00", currency: "USD" };

try {
  let result = await client.callTool("refund", args, { idempotencyKey: key });
  if (result.status === "approval_pending") {
    result = await client.waitForApproval(result, { timeoutSeconds: 600 });
  }
  if (result.status === "completed") console.log("done, receipt", result.receiptId);
  else console.log(explainResult(result));
} catch (e) {
  if (e instanceof KBlockUnavailableError && e.outcomeUnknown) {
    // The request may have run: the same key returns the stored outcome instead of running it again.
    const stored = await client.callTool("refund", args, { idempotencyKey: key });
    console.log(stored.status === "completed" ? "it had run" : explainResult(stored));
  } else {
    throw e;
  }
} finally {
  await client.close();
}

Next steps