kortecxdocs

Approvals

Let a run read, think and act on its own — but stop and ask you before it does anything it cannot take back.

An agentic run reads, decides and calls tools on its own. That is fine while it is reading. It stops being fine the moment it wants to send the email, post the message, or write the row.

Approvals is the switch for that boundary. Turn it on for a run and the run keeps going by itself — right up to the first action it cannot take back. That action is held: staged, not committed. It shows up in an inbox with the tool name attached, and it waits for you to say yes or no.

Nothing is guessed about your intent. The run does not "try it and see". It stops.

What gets held, and what does not

Every tool the runtime knows about declares an idempotency class — how the tool closes the loop if the same action were dispatched twice. The gate reads that declaration, and nothing else.

The tool's declared classWhat the gate does
Staged — the action has no self-closing dedupHeld. Waits for a decision.
AtLeastOnce — no closing mechanism at allHeld. Waits for a decision.
Token — the remote side accepts an idempotency keyNot held.
Readback — the runtime can check whether it already happenedNot held.

A read-only tool is normally declared Readback — for a read, the call is its own check — so it is not held, and a run under approvals still searches, fetches, reads files and reasons at full speed. The gate is keyed on the declared class, not on any separate notion of "read-only", so a tool that declares Staged is held whatever it actually does.

Tools reached through a connector are registered as Staged, because an outbound effect through someone else's API is treated as world-mutating unless proven otherwise. A tool you register yourself declares its own class:

kx tools register --name my-tool --version 1 --server-host localhost:9000 --idempotency-class Staged

The four accepted values are Token, Readback, Staged and AtLeastOnce. Without --idempotency-class a registration defaults to Readback, which is not held. If your tool really does change the world, say Staged.

Two limits on the classes themselves

AtLeastOnce is more than a gate marker: the executor refuses to dispatch an AtLeastOnce tool at all unless the submission explicitly accepts at-least-once delivery. Granting the approval is not on its own enough to make such a call fire.

Staged also names a deeper staged-intent recovery protocol in the tool registry. The approval gate reads the class today; that recovery protocol is declared but not yet enforced by the executor. This page describes only the approval-gate behavior.

Registering a tool is not the same as connecting to it

kx tools register records the declaration, including the class the gate reads. It does not on its own give you a working integration — dialing that host is a Cloud capability. The path that really calls out from the local runtime is Connections.

A separate thing from tool grants

Whether a run is allowed to call a tool at all is a grant, resolved server-side before the run starts. Approvals sit downstream of that: the run already holds the grant, and the gate still stops the call. See Tools.

Turn it on

There are three places to switch the gate on, from narrowest to widest.

For one run

kx app run apps/local/my-agent --require-approval

The per-run flag is not threaded through the Python run_app method — it takes args, wait and timeout only. Use the CLI or the TypeScript SDK to gate a single run. The Python SDK does have the full operator side (kx.approvals, below) and per-trigger gating.

import { KxClient } from "@kortecx/sdk";

const kx = new KxClient("http://127.0.0.1:50151");
const run = await kx.runApp("apps/local/my-agent", {
  requireApproval: true,
  wait: false,
});

Leave the flag off and the run behaves exactly as before — the gate injects nothing.

For an unattended trigger

This is the one that matters most. A trigger fires when nobody is watching — a schedule, or an inbound webhook. Gating the trigger means the unattended run still stops for a yes.

kx triggers add --name nightly-digest --kind cron --cron "0 9 * * 1-5" --timezone America/New_York --app apps/local/my-agent --require-approval --enabled
import kortecx as kx

with kx.KxClient("http://127.0.0.1:50151") as client:
    client.register_trigger(
        name="nightly-digest",
        kind="cron",
        schedule_spec="0 9 * * 1-5",
        timezone="America/New_York",
        app_handle="apps/local/my-agent",
        require_approval=True,
        enabled=True,
    )
import { KxClient } from "@kortecx/sdk";

const kx = new KxClient("http://127.0.0.1:50151");
await kx.registerTrigger({
  name: "nightly-digest",
  kind: "cron",
  scheduleSpec: "0 9 * * 1-5",
  timezone: "America/New_York",
  appHandle: "apps/local/my-agent",
  requireApproval: true,
  enabled: true,
});

The trigger name and the schedule above are an example — a 5-field cron expression evaluated in the IANA timezone you name. The run reads, reasons and prepares. Then it stops at the first held call and waits — for minutes or for days. A request carries no deadline in the local runtime, so a pending request does not expire on its own.

kx triggers list marks a gated trigger with hitl on its row.

For the whole server

Set the environment variable when you start the runtime and every new agentic chain gates its irreversible calls by default:

KX_SERVE_REQUIRE_APPROVAL=1 kx serve --dev-allow-local

The variable is read as truthy for 1, true, yes or on.

The per-run flag can only turn the gate on. It is a boolean that defaults to off, and an off value falls back to the server-wide setting — so there is no way from the CLI or the SDKs to opt one run out while KX_SERVE_REQUIRE_APPROVAL is set. The resolved posture is frozen onto the run when it starts, and recovery reads the recorded value rather than re-reading the environment, so a restart mid-run does not silently change the rules.

The inbox

See what is waiting

kx approvals list

Each line is one held action: the request id, the tool and version, and a short description of what it wants to do. The line format is request-id tool@version intent:

9f3c1a7b8d2e4f60a1b2c3d4e5f60718  my-tool@1  world-mutating tool call: my-tool@1

The id above is an example. A real request id is 16 server-derived bytes, printed as 32 hex characters — you copy it from this listing.

With nothing waiting it prints no pending approvals. Add --json for the machine-readable form, which also carries the run's instance id, the awaiting mote id and the creation timestamp.

Say yes

kx approvals grant 9f3c1a7b8d2e4f60a1b2c3d4e5f60718 --reason "checked the recipient list"

The held action is released and fires once. The run then continues on its own until the next held call — which stops and asks again.

--reason is optional. It is recorded in the journal alongside the decision.

Or say no

kx approvals deny 9f3c1a7b8d2e4f60a1b2c3d4e5f60718 --reason "wrong channel"

The tool never fires, and the chain fails closed: it dead-letters instead of skipping ahead or trying an alternative. A denial is a stop, not a detour.

The same three operations are on both SDKs:

kx approvals list --json
kx approvals grant <REQUEST_ID> --reason "ok"
kx approvals deny  <REQUEST_ID> --reason "not this one"

Every one of these takes the usual client flags — --endpoint, --token, --token-file, --tls-ca, --json.

import kortecx as kx

with kx.KxClient("http://127.0.0.1:50151") as client:
    page = client.approvals.list_pending()
    for a in page.approvals:
        print(a.request_id, a.tool_id, a.intent)

    decided = client.approvals.grant(page.approvals[0].request_id, reason="ok")
    print(decided)   # True iff a decision was recorded
import { KxClient } from "@kortecx/sdk";

const kx = new KxClient("http://127.0.0.1:50151");
const page = await kx.approvals.listPending();
for (const a of page.approvals) {
  console.log(a.requestId, a.toolId, a.intent);
}

const decided = await kx.approvals.grant(page.approvals[0].requestId, "ok");
console.log(decided); // true iff a decision was recorded

Held versus continuing — be precise

It helps to know exactly how much of the run is frozen.

  • Everything before the call is already committed. The reasoning turns, the reads, the tool calls that were not held — all of that is durable work in the journal. A pending approval is not a rollback point.
  • Only the one call is held. It is staged and not committed. Nothing about it has reached the outside world.
  • The hold is per chain. The request id is derived from that run's instance id and the specific proposed action, so the wait belongs to that chain rather than to the server as a whole. There is no global lock.
  • A grant releases the action once. Because the request id is derived rather than random, it is stable: if the runtime restarts while a request is pending, recovery re-derives the same id and reads the decision you already made, rather than asking again.
  • A second grant on the same id does nothing. Deciding an already-resolved or unknown request is an idempotent no-op; the CLI prints not granted (unknown or already-resolved request). Same for deny. That makes retrying an operator action safe.
  • A deny is terminal for that chain. It dead-letters. Terminal failures land in an inbox instead of vanishing — see Observability and kx alerts list.

You are the only thing that moves it

A pending request has no auto-expiry in the local runtime. If nobody grants or denies it, the run sits there. For an unattended schedule, make checking kx approvals list part of your routine, or use the approvals inbox in the console, which lists the same pending requests and offers the same grant and deny.

A worked example

Say you have an app that reads a support channel each morning and posts a summary back. Reading is safe. Posting is not. The app handle below is an example; use your own.

Start the runtime

kx serve --dev-allow-local

Run the app under the gate

kx app run apps/local/support-digest --require-approval

The run starts. It reads the channel and drafts the summary on its own.

It stops before it posts

kx approvals list

The post is held. Nothing has been sent. Read the intent line, and if you want the actual draft, look at the run's turns.

Decide

kx approvals grant <REQUEST_ID> --reason "summary looks right"

The post fires once, and the run continues. Had you denied it, nothing would have been posted and the chain would have dead-lettered — your reason recorded with the decision in the run's journal.

Limits worth knowing

  • --require-approval on kx app run needs a runtime that supports the server-side app run path. Against an older server the CLI refuses loudly rather than quietly running ungated — that refusal is the point. The TypeScript SDK is not as strict: for an app that declares no connections and no secret scope, runApp falls back to a legacy client-side path that drops requireApproval. Gate through the CLI, or through a trigger, if you need the refusal.
  • The per-run gate applies to the app's entry agentic step — the reason-tool-observe loop where tool calls are proposed. Agents chain inside one app (a blueprint graph, or kx swarm --pattern supervisor); there is no mechanism for one app to call another, so a gated run's boundary is that one app. Whether every chain inside a swarm inherits the same posture is not something this page can promise — gate the trigger or the run and check kx approvals list.
  • There is no auto-approve rule engine, no per-tool allow list for approvals, and no approval delegation. It is one inbox and two verbs. Acknowledging and resolving alerts, and any rule engine on top, are Cloud capabilities.
  • Approvals are single-system by default, like the rest of the runtime; multi-node is roadmap.

Next