KYC Agent
Gate high-value transactions on identity verification. Agent fires codespar_kyc, polls or streams verificationStatus, then routes to approved or rejected branch.
A KYC gate is the canonical pattern for any high-value transaction in LATAM. The agent receives a transfer request, fires a codespar_kyc inquiry, waits for the verdict (poll or stream), and routes to the approved branch (release the payment) or the rejected branch (notify the user, ask for a different document).
Prerequisites
npm install @codespar/sdk @codespar/openai openaiCODESPAR_API_KEY=csk_live_...
OPENAI_API_KEY=sk-...You need active accounts on Persona (default KYC rail), Asaas (Pix payouts), and Z-API (WhatsApp notifications). Persona requires inquiry_template_id stamped in connection_metadata when you connect — see the gotcha below.
The Persona inquiry_template_id gotcha
Persona's API is multi-tenant by template — every inquiry must reference an inquiry_template_id (e.g. itmpl_AbCdEf...). Rather than passing this on every session.execute(), CodeSpar stores it in connected_accounts.connection_metadata (jsonb) when the operator connects Persona.
When you click Connect on /dashboard/auth-configs for Persona, the connect modal asks for:
- Persona API key (the credential)
- Persona
inquiry_template_id(operator-stamped metadata)
Without the template id, every codespar_kyc call fails with inquiry_template_required. The error surfaces clearly in the dashboard's tool-call log; if you hit it, the fix is to re-open the Persona connection and add the template id.
Pattern: KYC gate before payout
The agent receives a request to release a R$ 50,000 payout. It fires KYC, polls until terminal, and only releases the payment if approved.
import OpenAI from "openai";
import { CodeSpar } from "@codespar/sdk";
import { getTools, handleToolCall } from "@codespar/openai";
const openai = new OpenAI();
const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });
async function payoutWithKYCGate(
userId: string,
payoutAmount: number,
recipient: { name: string; document: string; phone: string; pix_key: string }
) {
const session = await cs.create(userId, {
servers: ["persona", "asaas", "z-api"],
});
try {
// 1. Fire KYC inquiry
const inquiry = await session.execute("codespar_kyc", {
operation: "create_inquiry",
subject: {
name: recipient.name,
document: recipient.document,
document_type: "cpf",
},
metadata: { payout_amount: payoutAmount },
});
console.log("KYC fired:", inquiry.tool_call_id);
// 2. Poll until terminal
let v = await session.verificationStatus(inquiry.tool_call_id);
while (v.status === "pending") {
await new Promise((r) => setTimeout(r, 3000));
v = await session.verificationStatus(inquiry.tool_call_id);
}
// 3. Route on the verdict
if (v.status === "approved") {
const payout = await session.execute("codespar_pay", {
amount: payoutAmount,
currency: "BRL",
method: "pix",
recipient: {
document: recipient.document,
pix_key: recipient.pix_key,
},
idempotency_key: crypto.randomUUID(),
});
await session.execute("codespar_notify", {
recipient: recipient.phone,
channel: "whatsapp",
message: `Olá ${recipient.name}, sua identidade foi verificada e o pagamento foi liberado.`,
});
return { ok: true, payout_id: payout.tool_call_id };
}
if (v.status === "rejected" || v.status === "expired") {
await session.execute("codespar_notify", {
recipient: recipient.phone,
channel: "whatsapp",
message: `Olá ${recipient.name}, não conseguimos verificar sua identidade. Por favor, tente novamente com um documento diferente.`,
});
return { ok: false, reason: v.status };
}
// status === "review" — manual review pending; surface to operator dashboard
return { ok: false, reason: "manual_review" };
} finally {
await session.close();
}
}Streaming variant
Polling every 3 seconds is fine for a CLI script; for a live UX (a chat agent telling the user "verifying..." and then "you're approved"), use verificationStatusStream:
const inquiry = await session.execute("codespar_kyc", {
operation: "create_inquiry",
subject: { name: "Maria Silva", document: "12345678900", document_type: "cpf" },
});
await session.verificationStatusStream(inquiry.tool_call_id, {
onUpdate: (snapshot) => {
// Server pushes a `snapshot` event with the current state, then `update` events
// on each transition. Stream closes after a 5s grace following the terminal state.
console.log("kyc:", snapshot.status);
if (snapshot.status === "approved") {
releasePayment();
}
if (snapshot.status === "rejected") {
notifyUser("KYC failed — please try a different document.");
}
},
});The server emits a heartbeat every 15 seconds to prevent proxies from dropping the connection. After a terminal status, the server holds the stream open for 5 seconds so late subscribers receive the final snapshot, then closes.
Verdict priorities
When multiple events have landed for the same tool_call_id (e.g. review then approved), verificationStatus returns the highest-priority terminal state, in this order:
approvedrejectedreviewexpiredpending
This is why the polling loop above checks === "pending" (not !== "approved") — review is a stable terminal state that stays until human review resolves it.
Variations
Swap to Sift / Konduto / Truora
Pass provider: "sift" (or "konduto", "truora") on the codespar_kyc call — the rest of the flow is unchanged. Sift returns a fraud score rather than a binary verdict; map score thresholds to your own approve/reject logic.
Cache verdicts
A passed KYC inquiry is good for ~30-90 days at most providers. Cache the tool_call_id in your database keyed by recipient.document and skip the inquiry if a recent approved verdict exists.
Combine with codespar_charge
Same pattern, inverted: gate codespar_charge (inbound) on the buyer's KYC for high-value carts. Useful for marketplaces with seller-driven listings where the buyer's identity matters.
Next steps
Last updated on
Agent with a Wallet
End-to-end walkthrough of F2.M4 Programmable Wallets — create a wallet, bind an Asaas funding source, fund via sandbox Pix, execute a mandate-gated payment, watch the audit trail render. ~15 minutes.
Crypto Pay Agent
Generate a stablecoin payment URL via codespar_crypto_pay (Coinbase Commerce default), share with the user, watch for settlement webhook or paymentStatus.