There is a small class of operations that an agent should never run without human confirmation. The class is "operations that can't be undone with a click" — billing changes, email blasts, member removals, plan downgrades, anything that moves money or widens access.
For everything else, the agent runs autonomously. For these, you need a two-key handshake. This piece is the concrete pattern: the contract, the token shape, the failure modes, and the implementation we ship in production.
The contract
The agent makes a proposal call. The handler returns a confirmation token plus a human-readable summary. The agent surfaces the summary to its owner. The owner approves. The agent makes a commit call with the token. Only the commit call has a side effect.
Sequence:
- Agent → API:
upgrade_plan(org=acme, plan=scale) - API → Agent:
{ confirm_token: "tok_abc...", summary: "Upgrade Acme to Scale plan ($49/mo, charged immediately)" } - Agent → Owner: shows the summary in chat
- Owner → Agent: "yes, do it"
- Agent → API:
upgrade_plan(org=acme, plan=scale, confirm_token="tok_abc...") - API: validates token, executes upgrade, returns success
The handshake adds one network round-trip and one human checkpoint. That's the entire cost. The benefit is that the agent cannot, by accident or by malice, run the operation without the owner's explicit go-ahead.
What the token has to be
A consent token is single-use, time-bound, and bound to the exact operation and parameters that were proposed. Concretely:
PendingBillingConfirmation {
id string (random, ~256 bits)
org_id string
principal_id string -- the agent that proposed
operation string -- "upgrade_plan"
params json -- canonical-form serialization
created_at timestamp
expires_at timestamp (60s default)
consumed_at timestamp? -- nullable; set on first use
}
When the commit call arrives, the handler:
- Looks up the token by ID.
- Checks
consumed_at IS NULL— single-use. - Checks
expires_at > now()— time-bound. - Checks
principal_idmatches the calling agent — same agent. - Checks
operationmatches the called operation — same op. - Checks
paramsmatches the called params, canonical-form — same params. - Sets
consumed_at = now()in the same transaction as the side effect. - Returns success.
The order of operations matters: token consumption happens in the same transaction as the side effect. If the side effect fails, the token isn't consumed — the agent can retry. If the side effect succeeds and the transaction commits, the token is gone — a retry returns "already consumed."
What the token can't be
A few common mistakes that defeat the purpose:
Don't reuse the token across operations. If the agent can use the same token for upgrade_plan and add_member, the protection is gone. One token = one operation = one set of params.
Don't make the token cover only the operation, not the params. If the token is "you can upgrade Acme," the agent can substitute a different plan. The token must bind to the exact parameters proposed.
Don't hand the token back unencrypted in URLs or logs. The token is short-lived but it's also a confirmation — leaking it during the 60-second window allows an attacker who can intercept agent traffic to commit an operation the human approved for a different reason.
Don't accept a token from an agent that didn't propose it. Two agents shouldn't be able to share tokens. Bind to principal_id.
These are the canonical traps. Each one is small; each one defeats the protection.
What's in the summary
The summary is the most important UX surface in the handshake. It's the only thing the human reads before approving.
The summary should include:
- What the operation is, in plain English ("Upgrade Acme to Scale plan").
- The cost, in concrete units ("$49/mo, charged immediately").
- The time, if the operation is time-sensitive ("effective immediately" / "starting next billing cycle").
- The actor, so the human knows which agent is asking ("Argus is requesting").
- What is NOT changing, when there's risk of confusion ("seat count and integrations stay the same").
A bad summary: "Confirm upgrade?" The human can't evaluate this.
A good summary: "Argus is requesting to upgrade Acme from Pro ($19/mo) to Scale ($49/mo), effective immediately. Card will be charged $49 today and on the 25th of each month going forward."
The summary is the human's only checkpoint. The handler that mints the token is responsible for the summary. Don't let the agent write the summary — the agent is the actor, not the source of truth.
Failure modes from real use
A few things that have come up:
Agent proposes, owner takes too long, token expires. Fine — the agent re-proposes, gets a fresh token, surfaces the new summary. The user sees the same summary again with a fresh expiry. Cost: one extra round trip.
Agent proposes, owner approves, agent commits successfully, agent retries the same call. The token has been consumed. The retry returns "already consumed." The agent shouldn't loop on this — it's a sign the agent's success-detection logic is wrong, not that the system is broken. This is a useful forcing function.
Agent proposes operation A, owner reads the summary and replies "do operation B instead." The agent should propose operation B fresh, getting a new token, with a new summary. Don't let the agent reuse the original token — the human approved the original summary, not the new operation.
Agent forwards token to another agent. The other agent's principal_id doesn't match. Reject. This isn't a typical attack but it's a class to consider.
Token leaks via logs. Inside the 60-second window, an attacker with access to the agent's logs could commit. Treat tokens like credentials in logs — redact, don't print.
What needs a handshake, what doesn't
The shorthand: any operation that moves money, widens access, or can't be undone with a click. Concretely:
| Operation | Handshake? |
|---|---|
| Upgrade plan | Yes |
| Downgrade plan | Yes |
| Cancel subscription | Yes |
| Add member with admin role | Yes |
| Remove member | Yes |
| Delete workspace | Yes |
| Send email to mailing list | Yes |
| Make workspace public | Yes |
| Add a row | No |
| Edit a doc | No |
| Add a comment | No |
| Read a workspace | No |
| Search across workspaces | No |
The list grows as you ship features. The discipline is that adding a feature in the dangerous category requires adding a handshake as part of the feature, not later. We documented our internal contract in The dangerous-ops contract.
How this looks in practice
The pattern composes with everything else. An agent with scoped permissions still needs handshakes for the dangerous things in its scope. An agent with attribution still needs handshakes — the audit log shows who proposed and who approved separately. An agent with shape caps still needs handshakes — caps protect the substrate, handshakes protect the side effects.
The handshake is the smallest amount of human-in-the-loop that gets you safety on the operations that matter, without throttling the agent on the operations that don't. It's the right unit.
For the broader pattern of which operations should be gated and how, see Consent gates for dangerous operations. For the principles behind agent-first product design, see AI-agent-first primitives.
FAQ
What is a two-key handshake?
A pattern where an agent proposing a dangerous operation receives a confirmation token and a summary, surfaces the summary to its human owner, and only executes the operation by replaying the token after the human approves. The token is single-use, time-bound, and bound to the specific operation and parameters.
Why does the token need to be bound to the params?
Otherwise the agent can substitute params after the human approves. The human reads "upgrade to Scale, $49/mo" and approves; if the token only binds the operation, the agent could call upgrade_plan(plan=enterprise) with the same token. Binding to params makes the human's approval mean what it says.
What's the difference between a consent gate and a handshake?
In our usage, the consent gate is the high-level concept (some operations require human confirmation), and the two-key handshake is the specific implementation pattern (propose → token + summary → human approve → commit with token). They're the same idea at different abstraction levels.
Should I gate every write?
No. Gating reads or low-risk writes adds friction without adding safety. Gate operations that move money, widen access, or can't be undone with a click. For everything else, the agent runs without a handshake — and if you've done identity, attribution, scoped permissions, and shape caps right, the rest is safe enough by default.
What happens if the agent skips the proposal step and tries to commit directly?
The commit call has no token. The handler rejects it. There's no path to the side effect that doesn't go through the proposal. This is the structural protection — it's not enforced by the agent's good behavior, it's enforced by the handler.
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Two-key handshakes for irreversible agent actions",
"description": "The pattern: agent proposes, human confirms, only then does the action run. The token is the protection. Here's the contract, the failure modes, and the implementation.",
"datePublished": "2026-04-26",
"author": { "@type": "Person", "name": "Flint" },
"publisher": { "@type": "Organization", "name": "Dock", "url": "https://trydock.ai" },
"image": "https://trydock.ai/blog-mockups/style-d-dreamscape/two-key-handshakes-irreversible.webp",
"mainEntityOfPage": "https://trydock.ai/blog/two-key-handshakes-irreversible"
}
