Move your agent to Guard
A step-by-step path from “the agent calls Stripe itself” to “the agent proposes and COLVO executes”: observe first, then approve by hand, then let small, safe actions through automatically.
Checklist
- The suite is green. Your current agent version passes the refund and cancellation suite in COLVO Test, three attempts per scenario.
- Stripe is connected with a restricted key. Refunds and subscriptions write, everything else read or none. Only COLVO’s executor ever sees it.
- Your backend issues a mandate per ticket. Customer, payments, subscriptions, actions, limits and an expiry — decided by your code, never by the model.
- Observation mode for a week. The agent keeps acting as today and reports each action; COLVO records what it would have decided and checks the result in Stripe.
- Enforce with approvals. Remove the Stripe key from the agent, send every action through
POST /v1/operations, auto-allow set to zero so a human approves each one. - Raise auto-allow. Once reviewers approve the same small refunds every day, let them through automatically. Keep the kill-switch one click away.
1. Connect Stripe with a restricted key
In Stripe, create a restricted key with Refunds: write, Subscriptions: write, Customers, Charges, PaymentIntents, Invoices: read, everything else none. Connect it on the project page (Connections → Connect Stripe) together with the webhook signing secret; COLVO checks the permissions read-only (no test refund) and shows the webhook URL to add in Stripe with the events refund.created, refund.updated, charge.refunded, customer.subscription.updated, customer.subscription.deleted. Live keys must be restricted (rk_live_…); a full-access sk_live_ key is refused. It is encrypted per organisation and never returned by the API, shown in logs or passed to a model.
No Stripe account yet? POST /v1/projects/{id}/connections/sandbox gives you a simulated one to rehearse the whole path.
2. Issue a mandate from your backend
The mandate is the authority the agent works under. Your backend creates it when a ticket or conversation opens, from facts it already trusts — the logged-in customer, their orders — not from anything the model says. Full field reference: Mandates & connections.
// backend — when ticket #4711 opens (backend key, never shipped to the agent)
const mandate = await fetch('https://colvo.app/v1/mandates', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.COLVO_BACKEND_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
project_id: process.env.COLVO_PROJECT_ID,
source_request_id: ticket.id, // 'ticket_4711'
subject: { customer_id: customer.stripeId, payment_ids: orders.map((o) => o.paymentIntent), subscription_ids: [customer.subscriptionId] },
actions: ['refund.create', 'subscription.cancel'],
limits: { currency: 'eur', max_amount_minor: 5000, auto_allow_up_to_minor: 0, max_operations: 2,
cancel_modes_allowed: ['period_end'], cancel_modes_review: ['immediate'] },
expires_at: new Date(Date.now() + 24 * 3600_000).toISOString(),
}),
}).then((r) => r.json());
// hand only mandate.mandate_id and an agent key (cak_…) to the agent3. Observe first (nothing changes for customers)
In observation mode the agent keeps its current code path. After it acts — or decides not to — it reports what it did. COLVO evaluates the same policy it would enforce, stores the would-be decision, and reads the provider back to check the effect matches the reply. It never blocks, never executes and never needs write access.
POST /v1/observations — agent or backend key.
{ "mandate_id": "…", // optional: without one the would-be decision is DENY (no authority)
"source_request_id": "ticket_4711",
"action": "refund.create",
"parameters": { "payment_id": "pi_01", "amount_minor": 5000, "currency": "eur" },
"provider_id": "re_3Nx…", // the refund / subscription the agent touched, if any
"context": { "user_message": "…", "agent_reply": "Refunded €50 to your card." } }{ "observation_id": "…", "would_decide": "REVIEW",
"reasons": ["amount 5000 above auto-allow 0 (within max 5000)"],
"verify_state": "PENDING", // → VERIFIED | MISMATCH | UNVERIFIABLE
"flags": [] } // e.g. "would_deny", "state_mismatch", "no_mandate"Send "performed": false when the agent decided not to act — the would-be decision is still recorded, there is just nothing to verify. Observed actions under a mandate count against its limits exactly like operations would, so a second refund that would breach the total shows up as DENY. A verification mismatch opens an incident and fires your alerts, even though nothing was blocked.
The Observations page in the console shows each one with its would-be decision and verification, and a weekly summary: how many actions would have been allowed, sent for review or denied, and how many replies disagreed with the provider state. When that list is boring for a week, move on.
4. Enforce: one call replaces the Stripe call
Remove the Stripe key from the agent’s environment. Where it called Stripe, it now proposes. Start with auto_allow_up_to_minor: 0 so every action is REVIEW and a person approves it on the console’s Approvals page. COLVO emails every owner and editor when an action waits (and posts to alert channels subscribed to approvals), reminds them at 75 % of the window, and when nobody decides in time cancels the operation before anything is sent and tells them.
Node / TypeScript
npm install @colvo/sdk — zero dependencies, Node 18+. Retries are safe: the SDK sends a deterministic idempotency key, so a retry returns the original operation instead of doubling it.
// before
await stripe.refunds.create({ payment_intent: 'pi_01', amount: 5000 });
return reply('Refunded €50!');
// with Guard
import { Colvo, messageFor } from '@colvo/sdk';
const colvo = new Colvo({ apiKey: process.env.COLVO_AGENT_KEY }); // cak_… — can only propose and read
const op = await colvo.operations.propose({
mandate_id, source_request_id: ticket.id, action: 'refund.create',
parameters: { payment_id: 'pi_01', amount_minor: 5000, currency: 'eur' },
context: { user_message: lastUserMessage, agent_reply: draftReply },
});
return reply(messageFor(op)); // honest for ALLOW / REVIEW / HOLD / DENYPython
pip install colvo — standard library only, Python 3.9+.
import os
from colvo import Colvo, ColvoError, message_for
colvo = Colvo(os.environ["COLVO_AGENT_KEY"])
def propose_refund(mandate_id, ticket_id, payment_id, amount_minor, user_message):
op = colvo.operations.propose({
"mandate_id": mandate_id, "source_request_id": ticket_id,
"action": "refund.create",
"parameters": {"payment_id": payment_id, "amount_minor": amount_minor, "currency": "eur"},
"context": {"user_message": user_message},
})
return message_for(op) # ColvoError(409, "business_key_conflict") if a different effect was already proposedNo SDK in your stack? It is one HTTP call: POST /v1/operations with Authorization: Bearer cak_…, an Idempotency-Key header and the JSON body above — see the Guard API.
n8n
- Replace the Stripe node with an HTTP Request node:
POST https://colvo.app/v1/operations, headerAuthorization: Bearer {{ $env.COLVO_AGENT_KEY }}, headerIdempotency-Key: {{ $json.ticket_id }}:refund, JSON body as above. - Add a Switch node on
{{ $json.decision }}with four outputs: ALLOW, REVIEW, HOLD, DENY. - Each output sets the reply text (table below) before Respond to Webhook. Delete the Stripe credential from the workflow.
5. Tell the customer the truth
The decision tells the agent what actually happened. Map it to the reply in code, not in the prompt, so the model cannot promise a refund that was never sent.
| Decision | What happened | Reply along the lines of |
|---|---|---|
ALLOW | Queued; executed once, then verified in Stripe | “Your €50 refund is on its way back to your card.” |
REVIEW | Waiting for a person to approve the exact digest | “I’ve asked a colleague to approve your refund — you’ll get an email when it’s done.” |
HOLD | Not safe right now (paused, cap reached, provider unreadable); nothing sent | “I’ve logged your request; it will be processed shortly.” |
DENY | Outside the mandate; nothing sent, ever | “I can’t refund that on this order — the most I can refund is €50.” |
// @colvo/sdk and the Python package ship this as messageFor / message_for (with overrides); the logic, to adapt:
function messageFor(op: { decision: string; parameters: { amount_minor?: number } }) {
const eur = ((op.parameters.amount_minor ?? 0) / 100).toFixed(2);
switch (op.decision) {
case 'ALLOW': return `Your €${eur} refund is on its way back to your card.`;
case 'REVIEW': return `I've asked a colleague to approve your €${eur} refund — you'll get an email when it's done.`;
case 'HOLD': return "I've logged your request; it will be processed shortly.";
default: return "I can't do that on this order, but I've passed your request to the team.";
}
}6. Raise auto-allow
After a week or two of approvals, look at what reviewers approve every time — typically small refunds on the customer’s own recent payment. Set auto_allow_up_to_minor in the mandates your backend issues to that level (e.g. 2000). Everything above it keeps going to a person; everything outside the mandate stays DENY.
- Kill-switch: POST
/v1/projects/{id}/pause— every new proposal becomesHOLD, queued executions wait. - Alerts:
MISMATCHandUNVERIFIABLEopen an incident and fire your alert webhooks. - Keep testing: the CI gate stays on every agent release.
Optional: access lives in your own system
Many products grant access from their own database, not from Stripe. For cancellations, COLVO can read access from your service so “keeps access until the 31st” is verified end to end. Add a Service connection with a URL and a signing secret; COLVO calls it read-only:
GET https://api.yourco.com/colvo/access?customer_id=cus_01
X-Colvo-Signature: t=1790000000,v1=<hex hmac-sha256 of "t.customer_id">
200 { "customer_id": "cus_01", "access_enabled": true, "access_until": "2026-10-31T23:59:59Z" }After a period_end cancel, verification expects access_enabled: true and access_until at the end of the paid period; after an immediate cancel, access_enabled: false. A disagreement is a MISMATCH. The URL goes through the same egress rules as agent endpoints.
Want to see it before wiring anything? Try the interactive demo or book a 30-minute call and we’ll write the first mandate with you.