Write an AI agent approval workflow as an open-source policy file
An open-source approval workflow for an AI agent is a rule, a held call, a named decider and a log, and the rule belongs in a file you review as a diff. Kortix is the open-source AI Operating System.
One policy, two outcomes
policy: default_mode: risk policies: - match: gmail.send_email action: always_run conditions: - arg: to match: "*@example.com" - match: gmail.send_email action: require_approvalThe block Kortix publishes for Gmail: a send to an internal address runs, and a send to any other address waits.
The four parts of an approval workflow
A rule decides which call waits. A record holds that call while it waits. A person decides it. A log keeps the call and the decision together.
Most write-ups cover the person and stop there. That leaves the binding unstated, and the binding is what separates a control from a speed bump. A reviewer handed "the agent wants to send an email" has one sensible response, and it is not Deny. A reviewer handed the recipient, the subject and the account can judge the send.
Kortix puts the first two parts in the project repo. The rule is a policy file. The record is an execution_id on a call that returned 202 and ran nothing. The third part is a person with authority over the project, and the fourth is the audit trail that keeps both.
This is the split that decides most oversight arguments: the decision is made per action, not per system. Human in the loop names the person in front of the call, and the workflow is the machinery that puts them there.
The rule is a file, and the order is the design
A policy decides what happens when an action is called: Allow (always_run), Ask (require_approval) or Block (block). Kortix reads four layers in order and stops at the first match:
- Project-wide
policies:inkortix.yaml. - The connector's own
policies:. sensitive: trueon the connector.policy.default_mode.
No connector rule overrides a project rule. Company-wide lines therefore belong in the file, where they arrive as a diff someone can read.
policy:
default_mode: risk
policies:
- match: gmail.send_email
action: always_run
conditions:
- arg: to
match: "*@example.com"
- match: gmail.send_email
action: require_approvalpolicy.default_mode: risk sets the floor. Reads run. Writes and destructive actions are held. That one line is a working workflow on its own, because it decides every action the rules do not name. Approval gates you set. Off until you set them. A file with no policy: block falls back to allow_all, which runs every unmatched action.
The first rule allows gmail.send_email when to matches *@example.com. The second holds every remaining gmail.send_email. Two rules, one action, and the only difference between them is an argument.
Order carries the meaning. The first match wins, so the narrow allowance has to come first. Reverse the two rules and the general one matches every send, internal recipients included, and the allowance never runs. Reading a policy file is mostly reading it in order.
The rest of the condition syntax follows the same logic. arg is a dot path into the arguments, so message.channel reaches a nested field. A rule holds up to 10 conditions, and all must match. negate: true inverts one. A missing argument never matches the pattern, which is why a negated condition on an absent argument holds instead of passing. A malformed condition fails closed, so a typo narrows the gate rather than opening it. Patterns are globs, case-insensitive, or /regex/ in slashes.
The same three settings are reachable from a terminal: kortix connectors policy set --default risk --apply, kortix connectors policy add "gmail.send_*" ask --condition to='*@example.com', and kortix connectors policy gmail set send_email block.
What the approver is shown, and what one approval covers
Nothing runs. The call comes back 202, carrying status: "pending_approval", approval_url, approval_summary and execution_id, and that payload is what the person acts on.
The person opens the call in Review → Needs you, through approval_url, or on the approval card in a Slack or Teams thread. The page shows the redacted arguments the provider will receive, and the choice is Approve or Deny, with an optional message back to the agent. Redaction earns its place here: the reviewer decides on the arguments themselves rather than on a sentence the agent wrote about them.
The scope is one exact request: the same connector, action, arguments and account, and a changed argument needs a new approval. That rule is the difference between a decision and a nod in the direction of oversight. An approval that covers a class of future calls has already decided them, and the reviewer learns within a week that their answer does not depend on what they read.
After an approval, the identical call runs once when it repeats within 15 minutes. A retry loop therefore costs one decision, not fifteen.
A held call expires when nobody decides it within 24 hours, or within the project's approvals.ttl. approval_url expires at the same time, the card shows Expired, and the session does not resume. The agent asks again with a new call. Expiry is what keeps the queue a list of decisions instead of a list of messages.
There is no session-wide approval. An action that must run unattended needs an Allow rule. A project manager or the person who started the session decides a held call, and a session started with an API token is decided by project managers only.
A week of approvals shows which rules earn their place
Every action is recorded. Reading, exporting and streaming the audit log depend on the plan. Read one week of held calls and count three things.
Approvals that arrived unchanged. When nearly every held call is granted as written, the rule is drawing a line the work never crosses. Move that action to an Allow rule, or narrow the condition until the queue holds only the calls a person can actually improve.
Calls that expired. An expiry means nobody with authority opened it, or nobody could tell what they were approving. Both are routing problems. Change the approver or the summary, not the agent.
Rules that never matched. Project rules use fully qualified names, so an action renamed upstream leaves a rule pointing at nothing. A rule shadowed by an earlier match looks identical from the log: silent. Both are cost-free to delete and expensive to trust.
The number worth reporting is how many approvals changed on the way through, not how many were granted. A queue that grants everything, on identical arguments, week after week, is a formality, and the people in it know that before the audit does.
The code path runs the same gate
Session work reaches main through a change request. Merge is default-deny for agents.
The four parts repeat there. The rule is the merge gate. The record is the change request with its diff. The person is the reviewer. The log is the change request history and the audit trail.
Both mechanisms belong on one page because they make the same request of a person: read the actual change, then decide. A team that reviews a connector rule as a diff and then clicks through every email approval has built one control and one form, and only the diff is doing work. Human on the loop is where the second half of that habit gets tested, and how to choose sorts a single action into hold, supervise or allow.
What people ask about approval workflows
The file, the held call, the expiry, and the record left behind.
How do you build an approval workflow for an AI agent
Write the rule, then hold the call. In Kortix that is a policy: block in kortix.yaml with a default_mode and one policies: rule per action, and every held call carries an execution_id a person decides.
What happens when an agent's action needs approval
The call stops before it runs and comes back pending approval, with a link the approver opens. They see the redacted arguments the provider will receive, then Approve or Deny, and the agent's turn resumes on that decision.
How long does a pending approval last
24 hours from the call, or the project's approvals.ttl when one is set. After that the approval link stops working and the call shows Expired; the run does not continue and the agent has to ask again.
Can an agent approve its own action
No. A project manager or the person who started the session decides a held call, and a session started with an API token is decided by project managers only. There is no session-wide approval either.
Where is the audit record of an approved action
In the project's audit trail, where the call keeps its execution_id and the decision sits beside it. Anyone who can open the session sees the call and who decides it, with no Approve or Deny.
Put the gate on the calls a wrong yes would cost you.
Set one default mode, write one argument condition, and read the queue for a week.