The refund is where an open-source support agent stops
A support agent may read the thread, search the codebase for the bug, check the customer's invoices and send the reply without asking anyone
A support agent may read the thread, search the codebase for the bug, check the customer's invoices and send the reply without asking anyone. The refund is the call that should wait for a person. Kortix is the open-source AI Operating System: your agents, skills, memory, and connectors in one repo you own. The policy that splits those calls is a file in that repo.
Read, write, escalate decides nothing
Published guidance on what a customer service agent may do usually arrives as a tier list: read access, limited write, escalate. Uveler's guide is specific about the middle tier. It says an agent "may be allowed to perform low-risk actions such as creating a ticket, sending a document link or updating a communication preference", then puts refunds, contract changes, eligibility decisions and medical guidance in a class of high-consequence actions that should not be the first automation target (uveler.com).
The guide gets the stakes right, and the answer it gives is a sequence: wait before you automate a refund. The guidance does not say which refund may run today and which waits for a person, and the label cannot separate the two. A duplicate charge the invoices prove is arithmetic. A goodwill refund on a customer who had a bad month is a judgment. Both carry the same high-consequence label.
The tiers also hide the useful distinction inside a single word. Support teams call a reply a write, and so does the API. A reply goes to a customer and stays correctable. A refund is money that leaves. One of those needs a person's name on it before it happens, and the tier language gives no way to say which.
Three systems, three kinds of risk
An inbound support thread touches three systems, and they do not share a risk class. Searching the codebase for the path behind a bug report stays inside the building. The result is a file and a line, and a wrong answer costs a reply. A reply in the thread goes to the customer. Everyone can see it, the customer can answer it, and a colleague can take the same thread over.
A wrong reply is a sentence in a thread that is still open. A wrong refund is a payment that has left: someone has to explain the reconciliation, and the company has just put a price on a customer relationship. Undoing a refund takes a compensating payment and a conversation with finance. Correcting a reply takes a second message. That difference decides who signs, and no tier label carries it.
One rule per call, in a file a reviewer reads
Our own support runs this way. Every inbound thread in Plain opens its own session on Kortix, with access to the product's knowledge, the codebase and Stripe. The support use case walks through the setup. One isolated sandbox per session. Each session has its own isolated machine and branch. The agent investigates freely, and anything that touches a customer's money or account stops at a person.
What "stops at a person" means is the part worth writing down, and it fits in the manifest. This is the policy half of customer-support-approval-policy, a worked support project in the Kortix-seo GitHub account:
policy:
default_mode: risk
approvals:
ttl: 4h
policies:
# A duplicate charge is arithmetic: the invoice was submitted twice, the
# second charge is provable, and the refund is the correction. It runs.
- match: "stripe.create_refund"
action: always_run
conditions:
- arg: reason
match: "duplicate"
# Every other refund is a judgment about the customer relationship, which
# is the one thing an agent should not price. It waits for a person.
- match: "stripe.create_refund"
action: require_approval
- match: "stripe.update_subscription"
action: require_approval
- match: "helpdesk.reply_to_thread"
action: always_run
- match: "helpdesk.add_note"
action: always_run
- match: "github.*"
action: always_run
- match: "stripe.get_*"
action: always_run
- match: "stripe.list_*"
action: always_run
- match: "*.delete_*"
action: block
Approval gates you set. Off until you set them. Left at its out-of-box default, allow_all, a call that matches no rule runs, writes included. Setting default_mode: risk is what holds a write or a destructive call for a person (manifest reference). A rule matches a tool path such as stripe.create_refund, and a condition narrows it to calls whose arguments match. Kortix checks project rules first, then the connector's own rules, and the first match wins. The order matters: put the general refund rule above the duplicate exception and the exception never fires.
The *.delete_* line blocks the calls a support desk cannot undo. A deleted customer record, a deleted charge and a deleted thread do not come back, and blocking them removes the decision from the queue entirely.
What a held call looks like
A held call is not an error, and from the session it looks like an ordinary API response. Kortix returns 202 with status: "pending_approval", an approval_url, an approval summary and an execution id. Nothing ran (connectors). A person opens the approval in Review and sees the redacted arguments the provider would receive. They approve or deny it, with an optional message back to the agent.
An approval covers one exact request: the same connector, action, arguments and account. Change the amount on a refund and the agent needs a new decision. There is no session-wide approval either, so any call that has to run unattended needs an Allow rule of its own. When the ttl runs out the call expires, the approval link stops working, and the session does not resume. The agent asks again with a new call rather than resuming the old one.
Ten calls, six of them unassisted
The same repository holds check_policy.py, a small script that resolves a ten-call queue against that file, in the order the platform evaluates it. It is not the enforcement point. Kortix evaluates the rules when the call is made, and its decision is the one that runs. The script runs so a reviewer sees the answers before a session exists, and prints this:
call decision because
github.search_code run rule github.*
stripe.list_invoices run rule stripe.list_*
stripe.get_charge run rule stripe.get_*
helpdesk.reply_to_thread run rule helpdesk.reply_to_thread
helpdesk.add_note run rule helpdesk.add_note
stripe.create_refund run rule stripe.create_refund + condition
stripe.create_refund hold rule stripe.create_refund
stripe.update_subscription hold rule stripe.update_subscription
helpdesk.escalate_to_engineer hold default_mode: risk (write)
stripe.delete_customer block rule *.delete_*
10 calls: 6 run without a person, 3 wait for one, 1 blocked.
Two rows carry the argument. stripe.create_refund appears twice, one line apart in the file, with opposite outcomes. The first refund carries reason: duplicate, so the condition matches and it runs. The second carries requested_by_customer and waits. The amount does not decide; the reason does. A duplicate charge is arithmetic that the invoices prove, and a goodwill refund is a judgment about a customer the company wants to keep.
The row nobody wrote a rule for is the last one before the deletion. helpdesk.escalate_to_engineer matches no rule and falls through to default_mode: risk, which holds it because it is a write. That is the right answer for a call the author did not think about. It is also the row to show a security reviewer who asks what happens to the calls you missed.
What the agent leaves when it stops
An agent that hits a hold should not retry the same call with a different argument, and the agent file in that repository says so. It writes the diagnosis into the thread's internal note: what it found, which call it wanted to make, and why. The person deciding starts from an investigation instead of a blank ticket. A denial comes back with a message, and that message is the clearest evidence the playbook gets that the line sat in the wrong place.
The rules and the agent's instructions live in the same repository, so a boundary that was wrong arrives as a diff. Nothing merges itself: work reaches the default branch through a change request a person approves, unless an admin granted the merge capability. A support lead who wants the refund rule moved writes it and opens the change request. The person who owns the money reviews it.
This design has two ways to fail. The first is a queue nobody works. Hold every call and the decisions pile up until someone grants an Allow rule just to clear the list. That is why default_mode: risk matters more than a longer list of rules. The second is a hold that outlives the answer. A four-hour first-response target makes a 24-hour approval useless. The ttl belongs in the policy beside the rules, and a call that must never wait needs its own Allow rule.
Where this stops
A team that wants a vendor to own the policy, or a managed agent sold per resolution with the boundaries set in someone else's console, is buying a different product. That is reasonable when support is outsourced and nobody on staff wants to review a rule set. This setup suits a team that intends to keep the policy. The file is there to be read, and changed when a denial shows the line was in the wrong place.
Where a connector credential sits, and how Allow, Ask and Block resolve, is in how to give an agent tool access without handing over a key. The sales version of the same discipline, with a queue kept short by a conditioned rule, is why an agent's queue should stay small.
The first version of a support policy is one rule per call, and the refund rules are the ones to get right. Free to start, free to self-host, and the file is yours to argue with. Try Kortix.
More from the blog
Autonomous agents run on a number you set on the trigger
You have read a page of definitions of autonomous AI agents this week and none of them helps you compare two products
An open-source MCP connector holds the account the protocol left out
MCP says nothing about whose account a tool call runs as. Kortix is open source and pins each connector call to a named account under a rule in your repo.