Letting an AI assistant act without overreaching: action cards, a confirm gate and risk levels
Table of Contents
- Why not just give the model function calling?
- Gate one: rules pick the intent, the model only fills fields
- Gate two: every card needs a click
- Gate three: high risk needs two clicks, enforced by the server
- Gate four: re-read before running, refuse if things changed
- How is the audit trail kept?
- Test results and what is not done yet
- If you want to build this, do it in this order
When an in-app AI assistant moves from answering questions to doing things for you, the scary part is not that it misunderstands. It is that it misunderstands and acts anyway. This week, on the preview site of the upcoming MindThread 2.0, we gave Xiaomai, its in-app assistant, the ability to act: schedule a post, change posting times, adjust the persona's tone, add patrol keywords, turn cloud patrol on or off, and pause an account. The design in one line: the model never touches a tool. Rules decide what you want, the model fills in a card, the server stores it, nothing runs until you click confirm, high-risk actions need two clicks, the current state is re-read before execution, and every card is logged from creation to result. On our preview site, a walkthrough of 33 checks passed.
Our previous post covered how Xiaomai only quotes real numbers. That was reading. This one is writing, which is a level riskier.
Why not just give the model function calling?
A wrong read is a wrong answer. A wrong write is an account that stops posting, or a patrol that switches off, and the member may not notice for days.
The input is also untrusted. What the member types is one input, and the data Xiaomai reads includes posts and replies written by other people. Letting the model choose which write tool to call gives that text a path to influence writes.
And rules are testable. Intent routing, card fields, the confirm gate and risk levels are covered by 179 unit tests that run before every build. Model tool selection is hard to test that way.
The cost is flexibility. If a sentence does not match a rule, Xiaomai treats it as a question and answers it. We accept that.
Gate one: rules pick the intent, the model only fills fields
The six actions and their risk levels:
- Schedule a post: low
- Add patrol keywords: low; medium while cloud patrol is on
- Change posting times: medium
- Adjust persona tone: medium (one line appended to the end of the persona; the original text is untouched)
- Turn cloud patrol on: medium; turn it off: high
- Pause an account: high
Order matters. First, filter out sentences that are not requests to act: advice ("should I turn on patrol"), questions, negations ("don't turn it off") and past tense ("I already turned it off yesterday"). These go to the read-only Q&A flow. Rules will miss some cases; if a sentence is misread as an action, the result is one extra card waiting for your click, which is exactly why the confirm gate exists.
Scheduling is checked next. "Schedule a post about patrol" contains the word patrol; if toggles were checked first, it would be read as a patrol toggle. Then unsupported actions, then the other five kinds. English requests must start with a verb.
Four things Xiaomai will not do: delete posts, change plans, reply to comments and publish immediately. For these it says it cannot do that yet and links to the right page. It will not resume an account either; it asks you to switch that back on in account settings yourself. Resuming is not a card because switching an account from disabled back to enabled counts as loosening a guardrail in our system, which fires an "AI assistant loosened a guardrail" alert. That would mislead the member.
The model's only job is filling fields: which account, what time, the full text, which keywords. Code checks them again. Accounts are resolved only from the member's own account list; if there is no match, Xiaomai asks the member to name it. Scheduled times must be at least five minutes away. Post drafts use the account's persona and output language, then go through a fabrication check; if it flags something, the draft is rewritten once, and if it still fails, no card is made.
Risk is decided by code too, never by the model:
export function riskOf(kind, params) {
if (kind === 'schedule_post') return 'low';
if (kind === 'add_keywords') return params.patrolOn ? 'medium' : 'low';
if (kind === 'patrol_toggle' || kind === 'account_pause') return params.enabled === false ? 'high' : 'medium';
return 'medium';
}
Gate two: every card needs a click
Cards are created on the server and stored in the database. When you confirm, the browser sends only the card ID. Parameters always come from the server's copy, so editing the request in the browser cannot change what runs.
On confirm, the server runs the gate inside one transaction. This is a simplified version that leaves out the workspace-member and actor identity checks:
// Pure function, run in the same transaction that flips the status to 'executing'
export function confirmGate(card, ctx) {
if (!card) return { ok: false, code: 'not_found' };
if (card.ownerUid !== ctx.uid) return { ok: false, code: 'not_owner' };
if (card.status !== 'pending') return { ok: false, code: 'not_pending' }; // a double click stops here
if (card.id !== ctx.latestCardId) return { ok: false, code: 'not_latest' }; // only the newest card can run
if (ctx.nowMs > card.createdAtMs + 15 * 60 * 1000) return { ok: false, code: 'expired' };
if (card.risk === 'high') {
const first = card.firstConfirmedAtMs;
if (!(ctx.second === true && first && ctx.nowMs - first >= 1000)) {
return { ok: false, code: 'need_second', markFirst: !first }; // the server remembers the first click
}
}
return { ok: true };
}
A few details:
- One card at a time. Creating a new card marks the previous pending one as superseded in the same transaction and updates the "latest card" pointer.
- Fifteen-minute expiry. Settings may have changed by then, so an expired card asks the member to say it again.
- "Edit." The card is cancelled and the original sentence goes back into the input box.
- Owner only. Workspace members are blocked. They can ask, but not act.
Gate three: high risk needs two clicks, enforced by the server
Turning patrol off and pausing an account are the two actions we marked high. Our reasoning: things that stop make no noise. If patrol is turned on by mistake, you see extra replies. If it is turned off by mistake, nothing happens, and you only find out when results drop.
On the first click, the server only records when it happened and replies that a second confirmation is needed; the screen shows a warning and an "I understand, go ahead" button. The second request must carry second=true and arrive at least one second after the first.
Honestly, our first version enforced the second confirmation only in the frontend. Our own critic review caught it: sending second=true directly skipped it. Now the server records the first click and checks the gap.
Gate four: re-read before running, refuse if things changed
Say Xiaomai creates a card to change posting times from 09:30 and 21:30 to 09:00 and 21:00. Before you click, you go to settings and change the time to 10:00. If you then confirm, the card overwrites the whole field with 09:00 and 21:00, and your 10:00 is gone.
So right before running, the server re-reads the account and compares it with the "before" values on the card. If persona, times, keywords or a toggle differ, it does not run:
export function staleAgainst(card, account) {
if (!account) return true;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
switch (card.kind) {
case 'set_times': return !same(account.scheduleTimes, card.params.before);
case 'set_tone': return account.persona !== card.params.personaBefore;
case 'add_keywords': return !same(account.keywords, card.params.before);
case 'patrol_toggle': return account.patrolEnabled === card.params.enabled;
case 'account_pause': return account.enabled === card.params.enabled;
default: return false;
}
}
Execution adds no new write paths. Xiaomai calls the same APIs the web app already uses, and their existing checks still run: account ownership, formats, content rules, the global pause switch and plan limits. Xiaomai cannot do anything you could not do yourself by clicking in the app.
How is the audit trail kept?
Every card, from creation through the first confirmation time, confirmation, result and cancellation, lives in a server-side card collection. Our database rules do not expose that collection, so every read and write from the frontend is denied and only the server can write. That makes it a read-only audit log.
Results go back into the chat and also create a notification that links to the relevant settings page. When a guardrail-related setting changes (pausing an account, for example), the guardrail change log records it as usual, with the actor marked as Xiaomai plus the member's ID, so you can later see who did what and through which path.
Quota is clear too: generating an action card counts as AI usage under the same daily limit as Q&A and is refunded if generation fails; confirming and executing costs nothing extra.
Test results and what is not done yet
On the preview site, with a test account, we walked through all six actions plus "edit" and cancelling in the English interface: 33 of 33 passed. In the database, 7 cards executed and 2 were cancelled, both high-risk cards recorded both confirmations, and the console showed no errors. The walkthrough caught three text and layout issues; the fixes were verified with the build and unit tests, without rerunning the whole walkthrough.
What is not done, plainly:
- High-risk confirmation is a second click, not typing something. A stricter version could ask for the account name.
- For accounts outside Taiwan, scheduled times may be off by an hour during the daylight saving switch hour. Taiwan is not affected.
- The preview site has no scheduler, so "the scheduled post actually goes out on time" was not verified there. That step runs through the same existing pipeline as manually scheduled posts.
If you want to build this, do it in this order
- List the actions you will allow. For each, write down which field it changes and which existing API it uses.
- Detect intent with rules. Filter out advice, questions, negations and past tense first.
- The model outputs fields only. Code validates the fields and assigns the risk level.
- Store the card on the server and send only its ID from the browser. Run the confirm gate inside a transaction and flip the status when it passes.
- Let the server record the time of the first click for high-risk actions.
- Re-read the current state before executing and compare it with the card's "before".
- Make the card collection server-write-only and treat it as your audit log.
None of this needs a very clever model. It needs you to decide up front what you would rather not do. The Xiaomai in the upcoming MindThread 2.0 is built on exactly these seven steps.
FAQ
How do you stop an AI assistant from doing the wrong thing when it acts for a user?
Do not let the model decide what to call. Rules decide which kind of action the user wants, the model only fills in the card fields, code validates those fields, and the server stores the card. When the user confirms, the browser sends only the card ID; the parameters always come from the server's copy, and a confirm gate checks the card before anything runs. Advice questions, plain questions, negations and past tense are filtered out by rules first and only get an answer; if a sentence is misread, the worst case is one extra card waiting for your click, never a direct action.
Why do high-risk actions need two clicks, and why must the server check them?
Because once cloud patrol is off or an account is paused, the system goes quiet and nothing reminds you it stopped. On the first click the server only records the time and replies that a second confirmation is needed. The second request must carry second=true and arrive at least one second after the first. If only the frontend enforced this, anyone could send second=true directly and skip it. Our first version did exactly that, and our own review caught it.
What if the setting changes between creating the card and confirming it?
Re-read the account right before executing and compare it with the 'before' values stored on the card. If the persona, posting times, keywords or a toggle no longer match, do not run; reply that things have changed and ask the user to say it again. Otherwise the card's whole-field overwrite would erase the change the user just made on the settings page.