BetaFindAgent is in free public beta — every agent is free to connect and paid agents aren't available yet.
Handing an agent an API key is handing it to every host the agent can reach. Audience-bound credential slots invert that: the runtime attaches a secret only when the destination host matches a declared list.
A guardrail written into a system prompt is a request. A guardrail enforced at the gateway is a rule. Here's the full guardrail surface and a ready-made block for each class of tool.
Every rejection we issue falls into six buckets. Four are caught automatically before a human looks, and all six are things you can check yourself in about ten minutes.
We removed two tools from our own agent catalogue during development: issue_refund and
archive_flag. Both were well-designed, both were things people actually wanted, and both leaned on
the same fragile assumption: that the agent could stop halfway through and ask a human a question.
Sometimes it can. When the connecting client supports MCP elicitation, a tool marked
approval: "human" really does surface a "Approve this action?" prompt mid-call — approve and it
runs, decline and it's refused. But that channel isn't guaranteed. Over the stateless hosted
gateway the server can't round-trip a question back to the client, so the same gate has to fall
back to something else.
This post is about where mid-call approval works, where it degrades, why the difference is architectural rather than a bug, and what to build so the behaviour is identical everywhere — which turns out to be a better design anyway.
On the hosted path, an MCP client speaks to the FindAgent gateway, which invokes the runtime, which performs the action. Each tool call is self-contained: it arrives with everything it needs, executes, and returns a result. There is no session sitting in memory between calls waiting to be resumed.
That property is what makes hosted execution safe and scalable — no server-side state to leak between tenants, no half-finished work pinned to a particular process, any run can land anywhere, and a crash loses nothing but the call.
It also removes the thing a mid-call elicitation round-trip needs on this path: a server-to-client question that pauses the call while a human thinks. Elicitation is a request the server sends back to the client; the stateless gateway has no open channel to send it on, so that request can't be made here.
sequenceDiagram
participant U as User
participant C as MCP client
participant G as Gateway (stateless)
participant API as Payments API
U->>C: "Refund order 4471"
C->>G: tools/call issue_refund {order: 4471}
G->>G: Compute amount: $240
Note over G: Wants to ask:<br/>"Refund $240 to Jane D.?<br/>Confirm."
G--xC: ❌ No open channel to round-trip<br/>a server→client question here
Note over G: The call must complete, fall back<br/>to a confirm step, or refuse — right now.The gateway's options at that moment are narrow. It can't pause and ask. For a plain approval: "human"
gate it falls back to a confirm step — return "re-run this with confirmation" so the call is
visible in the transcript before it proceeds. That's genuine friction, but a model can satisfy it
by re-invoking, so it isn't a human checkpoint on this path. For an approval: "human_strict" gate it
does the only safe thing when it truly can't ask: it refuses, fail-closed.
So a tool that depends on a guaranteed human answer shouldn't be shaped as one call. We deleted ours and rebuilt them as a pair.
approval: human actually doesThis is where the guardrail field gets misread — in both directions. approval: "human" is neither
"always a modal" nor "always a hard block." What it does depends on whether the client can be asked,
and there are two levels of it.
Where the client supports elicitation, the runtime turns the gate into a real prompt: the server asks the client "Approve this action?", the human answers, and the call proceeds on approve or is refused on decline. That is a genuine mid-call human checkpoint.
Where it can't ask — the stateless hosted gateway, a client that doesn't offer elicitation, or an unattended run inside a Department — the two levels diverge:
| Level | Client can be asked | Client can't be asked |
|---|---|---|
approval: "human" |
Interactive "Approve?" prompt; approve runs, decline refuses | Confirm step: re-run with confirmation (a model can satisfy it — friction and transcript, not a human gate) |
approval: "human_strict" |
Interactive "Approve?" prompt; approve runs, decline refuses | Fail closed — refused, because a human answer that can't be obtained is treated as "no" |
Both are genuinely valuable — they're the difference between an injected instruction succeeding and
failing, and human_strict guarantees a fail-closed stop for the irreversible cases. But note the
asymmetry: on a path that can't elicit, plain human proceeds after a confirm step. If you need a
real person in the loop on every client, don't rely on the confirm fallback — either mark it
human_strict (accepting that it will refuse where it can't ask) or, better, use the two-call shape
below, which puts a human decision in the client's own conversation on every path.
Split the irreversible operation into two tools.
sequenceDiagram
participant U as User
participant C as MCP client
participant G as Gateway
participant API as Payments API
U->>C: "Refund order 4471"
C->>G: tools/call prepare_refund {order: 4471}
G->>API: GET order 4471
API-->>G: total $240, customer Jane D., paid 12 Jul
G-->>C: proposal_id: pr_8f2a<br/>amount $240 · Jane D. · reason: damaged
C->>U: "This would refund $240 to Jane D.<br/>for a damaged item. Proceed?"
U->>C: "Yes"
C->>G: tools/call execute_refund {proposal_id: pr_8f2a}
Note over G: approval: human · max_amount: 500<br/>idempotent: false
G->>API: POST refund
API-->>G: refunded
G-->>C: ✅ Refunded $240 · ref rf_31c9Two calls, one conversation, no suspended state anywhere. Each call is self-contained, which is exactly what the transport requires.
The human sees a rendered proposal, not a modal. The confirmation appears in the client's own interface, with the surrounding conversation intact — far more context than a dialog box could carry.
The proposal is inspectable and disputable. "$240 to Jane D. for a damaged item" can be argued with before anything happens. A yes/no prompt on an opaque action cannot.
The client already has the right primitives. It has the human, the turn structure and the rendering. Rebuilding that inside a tool call would be a worse version of something that already exists one layer up.
It composes. A proposal can be produced in one session, reviewed, and executed later. A suspended call cannot survive a page refresh.
prepare_* is read-only and idempotent{
"name": "prepare_refund",
"description": "Calculates the refund that would be issued for an order and returns a proposal for review, without changing anything.",
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": true
}
}Guardrail: { "approval": "none", "rate_limit": "60/hour", "idempotent": true }
It costs nothing to call and nothing to repeat, so it needs no gate.
execute_* takes the proposal, not the parameters{
"name": "execute_refund",
"description": "Issues a refund that was previously prepared, identified by its proposal id.",
"input_schema": {
"type": "object",
"properties": {
"proposal_id": { "type": "string", "description": "Identifier returned by prepare_refund." }
},
"required": ["proposal_id"]
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": true
}
}Guardrail: { "approval": "human", "rate_limit": "10/hour", "idempotent": false, "max_amount": 500 }
Take the proposal id, not the amount. If execute_refund accepted {order, amount} directly,
the model could construct a call that no human ever saw a proposal for — and the whole review step
becomes decorative. Making the identifier the only input means an execution can always be traced to
a specific proposal that was shown to someone.
If the upstream API supports an idempotency key, mint it during prepare_* and reuse it during
execute_*. A repeated execute_refund with the same proposal_id then resolves to the same
upstream operation rather than a second one.
This is how you get retry safety without marking a creating call idempotent: true — the safety
lives in the identifier rather than in a promise about the call.
When asked to perform a refund, always call prepare_refund first, present the proposal to
the user in full, and wait for explicit confirmation before calling execute_refund. Never
call execute_refund with a proposal the user has not seen.
Belt and braces. The system prompt shapes the normal path; the two-tool structure is what holds when the prompt is ignored or subverted. Neither alone is enough — and note which one is load-bearing: the structure, not the sentence.
The pattern applies to every irreversible operation, not just money:
| Operation | Propose | Execute |
|---|---|---|
| Refund | prepare_refund |
execute_refund |
| Customer-facing email | draft_reply |
send_reply |
| Publish a release | draft_release_notes |
publish_release |
| Delete records | list_deletion_candidates |
confirm_deletion |
| Bulk update | preview_changes |
apply_changes |
Rule of thumb: if you find yourself wanting a confirmation dialog inside a tool, you have two tools.
And the corollary for the local path: even where a client can elicit, the two-call shape is still the better design. It survives across clients, across sessions, and across the local/hosted split without a rewrite.
| Symptom | Cause | Fix |
|---|---|---|
| Confirmation prompt appears on one client but not another | Elicitation isn't available on every client (e.g. the stateless hosted gateway) | Split into propose + execute so the decision lives in the conversation everywhere |
| An irreversible action ran without a real human review | Relied on plain approval: human, which becomes a model-satisfiable confirm step where elicitation is unavailable |
Use human_strict, or the two-call shape, when a person must decide on every path |
| An irreversible action ran without review | execute_* accepts raw parameters instead of a proposal id |
Make the proposal id the only input |
| Duplicate refunds after a retry | No single-use identifier | Mint an idempotency key in prepare_* and reuse it |
| Agent skips the proposal step | Only the system prompt enforces it | The structure has to enforce it, not the sentence |
human_strict tool refuses on the hosted gateway |
It fails closed when it can't obtain a human answer | Expected — pair it with a prepare_* step so the human decides in the conversation first |
| Works on one client, behaves differently on another | Design depends on client elicitation | Design for the stateless path; the two-call shape behaves the same on all paths |
prepare_* / execute_* pairprepare_* is read-only, approval: none, idempotent: trueexecute_* carries an approval gate (human_strict where a person must decide even when the client can't be asked), idempotent: false, with max_amount where money movesexecute_* takes a proposal identifier, never the raw parametersdestructiveHint: true on the execute toolCan a hosted MCP agent ask the user a question mid-task?
It depends on the client. When the client supports MCP elicitation, an approval: "human" tool
surfaces a real "Approve this action?" prompt mid-call. But the stateless hosted gateway can't
round-trip a server-to-client question, so on that path the gate can't pause the call — it falls back
to a confirm step or, for human_strict, refuses.
What does approval: human actually do then?
Where the client can be asked, it prompts the human interactively (approve runs, decline refuses).
Where it can't, plain human degrades to a confirm step — re-run with confirmation, which a model
can satisfy, so it's friction and transcript rather than a human gate — and human_strict fails
closed and refuses. Use human_strict, or the two-call pattern, when a person must decide on every
client.
What replaces mid-flow confirmation when you need it everywhere?
A two-call pattern: a read-only prepare_* tool returns a proposal, the client's conversation
presents it to the human, and a separate execute_* tool performs the action given the proposal's
identifier. It puts the human decision in the conversation on every path, elicitation or not.
Why should execute take a proposal id instead of the parameters? Because accepting raw parameters lets the model construct an execution nobody reviewed, which makes the review step decorative. Taking only an identifier means every execution traces back to a proposal that was actually shown to a human.
How do I stop a retry from double-executing? Mint a single-use identifier — ideally the upstream API's idempotency key — during the prepare step and reuse it at execute. Retry safety then lives in the identifier rather than in a promise about the call.
Is elicitation supported at all? Yes — on clients that offer it, the runtime turns an approval gate into a real "Approve?" prompt. What it can't do is round-trip that prompt over the stateless hosted gateway, and that limit is architectural rather than a missing feature: statelessness is what makes hosted execution isolated and scalable. The two-call pattern isn't a workaround — it's a better design that behaves the same whether or not the client can be asked.
Related: Guardrails at the gateway · The MCP Agent Manifest Cookbook · Connect vs install