New to Kestra?
Use blueprints to kickstart your first workflows.
Let an AI agent request destructive actions over MCP. Staging runs freely, production needs human approval, duplicate keys are claimed once in Postgres.
Give an AI agent a destructive tool it can call over MCP, without giving it the power to approve itself. Staging runs immediately; production returns a request id at once, waits for a human in Kestra, and the same idempotency key can never trigger the purge twice.
This blueprint is three flows that work together: mcp-governed-action-request (this MCP tool), mcp-governed-action-executor (claims the request, asks a human when needed, runs the purge) and mcp-governed-action-status (MCP tool to follow up a request).
claim_key inserts the request into public.governed_requests. The primary key makes this atomic: with INSERT ... ON CONFLICT (request_key) DO UPDATE ... WHERE status IN ('expired','rejected','failed'), concurrent calls with the same key claim it once.mcp-governed-action-executor: staging waits for it, production does not (the agent never waits on a human). For production, record_executor_id stores the executor execution id in the row.peek_request, settle_duplicate and read_request read the row. A duplicate that arrives a few milliseconds after the winner waits one second if the executor id is not stored yet.build_response returns the status. A key that exists with a different environment or days returns conflict and starts nothing. A key that was executed is never reused.destructive, readOnly, idempotent, openWorld) are hints for the agent. They enforce nothing.decision == 'approve' check and by SQL guards: the row must be running for this executor execution.POST /api/v1/{tenant}/executions/{id}/actions/resume). On Kestra OSS 2.0.4, a private MCP server uses Kestra basic auth, the same login that can call that endpoint. Keep the login in the MCP client configuration, out of the agent's reach: never give the agent a shell, an HTTP tool, or a file it can read that holds those credentials.GOVERNED_EXECUTOR_WEBHOOK_KEY secret). It cannot bypass approval, but anyone with the key can take over a claimed request.governed_action_request, with a fixed set of status values and an approval_execution_id the agent can hand to a human.executed status in one SQL statement, guarded so it only runs for an approved, claimed row.Teams that want to give AI agents write access to real systems, with a human in the loop for production. purge_and_record in the executor is the slot for your own destructive action: replace the demo DELETE with yours and keep its guards (the EXISTS ... status = 'running' check and the executed update in the same statement).
environment, decision and decided_by labels on the executor, and the decision stored in the row.governed_action_status.On Kestra 2.0.4 OSS with Postgres 16, a private MCP server with basic auth and a local Slack stub. The flows ran unmodified (only extend removed, because the flow API rejects it), as the governed_actions role created by the SQL below. That SQL was run twice on a fresh database: both runs succeed, and the second changes nothing.
expired, rejected and failed.pending_unnotified, approval still works, and calling the status tool re-sends the link after 10 minutes (tested by back-dating the row 11 minutes): one message, none before.claimed row is recovered by a direct run or a Restart.deny never reach the DELETE; an invalid value is rejected with 422. A real Pause expiry ends in expired with nothing deleted (tested with a 20-second copy of the pause, not the 1-hour literal).DELETE statement:DELETE.DELETE.DELETEs, 25 Slack messages, then 13 approved and 12 denied gave 13 production DELETEs.executed.default.PG_PASSWORD (password of the governed_actions role), SLACK_WEBHOOK_URL (Slack incoming webhook) and GOVERNED_EXECUTOR_WEBHOOK_KEY (a long random value). On Kestra OSS, secrets are SECRET_-prefixed, base64-encoded environment variables.variables, replaced in all three flows.Run as a database owner. Replace the password. It is safe to run twice: the role is only created when missing and the demo rows are only inserted into empty tables. CREATE TABLE IF NOT EXISTS does not upgrade an older governed_requests table: drop or migrate it yourself.
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'governed_actions') THEN
CREATE ROLE governed_actions LOGIN PASSWORD 'change-me';
END IF;
END
$$;
-- Demo target tables: the purge deletes old rows from these.
CREATE SCHEMA IF NOT EXISTS staging;
CREATE SCHEMA IF NOT EXISTS production;
CREATE TABLE IF NOT EXISTS staging.demo_events (
id bigserial PRIMARY KEY,
payload text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS production.demo_events (
id bigserial PRIMARY KEY,
payload text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Request state. The primary key on request_key makes the claim atomic.
CREATE TABLE IF NOT EXISTS public.governed_requests (
request_key text PRIMARY KEY CHECK (request_key ~ '^[A-Za-z0-9_-]{1,64}$'),
environment text NOT NULL CHECK (environment IN ('staging', 'production')),
days int NOT NULL CHECK (days BETWEEN 1 AND 3650),
status text NOT NULL CHECK (status IN (
'claimed', 'pending', 'pending_unnotified', 'running',
'executed', 'rejected', 'expired', 'failed')),
request_execution_id text,
executor_execution_id text,
decided_by text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Least privilege: the flows can read and write request state and delete demo rows, nothing else.
GRANT USAGE ON SCHEMA staging, production TO governed_actions;
GRANT SELECT, DELETE ON staging.demo_events, production.demo_events TO governed_actions;
GRANT SELECT, INSERT, UPDATE ON public.governed_requests TO governed_actions;
-- Optional demo data: 5 old rows per environment, only into empty tables.
INSERT INTO staging.demo_events (payload, created_at) SELECT 'old', now() - interval '400 days' FROM generate_series(1, 5) WHERE NOT EXISTS (SELECT 1 FROM staging.demo_events);
INSERT INTO production.demo_events (payload, created_at) SELECT 'old', now() - interval '400 days' FROM generate_series(1, 5) WHERE NOT EXISTS (SELECT 1 FROM production.demo_events);
variables in all three flows: jdbc_url, jdbc_user, and kestra_url (executor and status). Set kestra_url to your own Kestra URL: kestra.url is unset in Kestra 2.0.4, so the Slack approval links would otherwise point to http://localhost:8080.mcp-governed-action-executor, then this flow, then mcp-governed-action-status, all into the SAME namespace: this flow starts the executor in its own namespace, and the status tool links to it.default MCP server and call governed_action_request. Read the security model first.Ideas, not tested in this blueprint:
DELETE in purge_and_record (executor) with your own destructive action, and keep its guards.schemas map in the executor's variables and the table name in purge_and_record. The environment and days always come from the claimed row, never from the agent's input.environment (SELECT: staging, production, required).days (INT, required, 1 to 3650): keep the rows from the last N days, older rows are deleted.key (STRING, required, ^[A-Za-z0-9_-]{1,64}$): idempotency key.status: pending, pending_unnotified, running, executed, rejected, expired, failed, claimed or conflict. Production returns pending.request_id: the key.approval_execution_id: the executor execution. For production it is the execution waiting for approval, for staging the execution that ran. A duplicate returns the same id. It is empty on conflict, or when the executor has not started yet.duplicate: true when the key was not claimed by this call.record_executor_id fails after the production executor started, the call returns an error although a request is pending. A retry with the same key is safe: it returns the existing request and its executor id.claimed. See the executor flow for recovery, and for resetting a killed executor.This blueprint was created by daiwangk.
