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:
| Situation | When the Gateway executes |
|---|---|
| The policy allows the call | At once, in the same tools/call request |
| The call needed approval and was approved | When the agent repeats the identical call |
The agent was waiting with _meta["kblock/wait-seconds"] when the approval landed | In 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
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
approvedand can still run later if the problem is fixed in time, unless the tool's binding changed (see Approvals).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_CHANGEDorOBSERVE_ENDED, nothing consumed). It then moves the action fromapprovedtoexecutingand records the execution. From this point the approval is used up.The upstream call
The Gateway sends the
tools/callto the upstream MCP server once, over Streamable HTTP, with the storedAuthorizationheader. A K-Block-aware upstream (one registered with a server KID) also receives the evidence in_meta.kblockand must return an attestation.Recording the outcome
The Gateway signs a RESULT with its service identity, issues the receipt, stores both, and moves the action to
succeededorfailed. 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:
| Scope | Guarantee |
|---|---|
| One protected action, one approval request | At 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 calls | Merged 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 call | Sent 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
| Outcome | Agent receives | Receipt |
|---|---|---|
| The upstream returned a result | The upstream's content and structuredContent, plus _meta.kblock | Yes |
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.upstream | Yes |
| A K-Block-aware upstream refused without attesting | upstream_error / UPSTREAM_REFUSED | Yes |
| The upstream could not be reached, timed out, or answered badly | upstream_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_ERROR | Yes |
| The upstream answered, but its result cannot be stored or bound into the evidence | upstream_error / OUTPUT_NOT_STORABLE or OUTPUT_NOT_CANONICAL | Yes |
| A K-Block-aware upstream's attestation is missing, does not verify, or cannot be stored | upstream_error / ATTESTATION_MISSING, ATTESTATION_INVALID or ATTESTATION_NOT_STORABLE | Yes |
| A check before consumption failed | A refusal such as denied / UPSTREAM_CHANGED, or upstream_error / UPSTREAM_BLOCKED — nothing consumed | No |
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.
| Case | What the agent receives | What happened |
|---|---|---|
| K-Block became temporarily unavailable while the approval was being consumed | unavailable / DATABASE_UNAVAILABLE, retryable: false | The 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 all | upstream_error / RESULT_NOT_RECORDED, without a receipt | The 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 is | upstream_error / RESULT_NOT_STORABLE | A failure is recorded instead of the result. The upstream may have succeeded |
| The Gateway failed while calling the upstream or processing its answer | upstream_error / GATEWAY_ERROR | The upstream may have acted |
| The Gateway stopped during the execution | A transport error or timeout in the client | Recovery marks the execution failed (INTERRUPTED) |
| The connection between agent and Gateway broke after the request was sent | The 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.
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();
}