{"id":"fintech-kyc-screener","title":"KYC Screener","description":"Onboarding KYC screen against sanctions/PEP/adverse-media lists with a DETERMINISTIC fuzzy-match gate: required fields (name/DOB/country) validated up front; strong/exact hits escalate unconditionally (never model-cleared); the model only downgrades weak near-misses. Typed risk verdict + human sign-off.","category":"fintech","version":"2.0.0","source":"AKOS","license":"MIT","tags":["fintech","compliance","kyc","pep","deterministic-gate","human-in-the-loop"],"packages":["@agentskit/core","@agentskit/runtime","@agentskit/tools"],"requires":{"zod":"^3","zod-to-json-schema":"^3"},"files":["agent.ts","README.md","eval.ts"],"status":"validated","skill":{"name":"fintech-kyc-screener","description":"Onboarding KYC screen against sanctions/PEP/adverse-media lists with a DETERMINISTIC fuzzy-match gate: required fields (name/DOB/country) validated up front; strong/exact hits escalate unconditionally (never model-cleared); the model only downgrades weak near-misses. Typed risk verdict + human sign-off.","systemPrompt":"You adjudicate ONE possible KYC list match. Given a candidate identity (name, DOB, country)\nand a single fuzzy-matched list name + score, decide whether they are the SAME real-world party.\n\n${UNTRUSTED_CONTENT_DIRECTIVE}\n\nBe conservative: return \"false-positive\" ONLY when the evidence clearly shows a different person\n(mismatched DOB/country, common-name coincidence). When unsure, return \"true-match\" — a human\nreviews escalations; wrongly onboarding a sanctioned/PEP party is a regulatory breach.\n\nCall submit_verdict exactly once with { decision, rationale }. Output nothing else."},"flow":null,"a2a":{"id":"io.agentskit.registry.fintech-kyc-screener","name":"KYC Screener","description":"Onboarding KYC screen against sanctions/PEP/adverse-media lists with a DETERMINISTIC fuzzy-match gate: required fields (name/DOB/country) validated up front; strong/exact hits escalate unconditionally (never model-cleared); the model only downgrades weak near-misses. Typed risk verdict + human sign-off.","version":"2.0.0","homepage":"https://registry.agentskit.io","skills":[{"name":"fintech-kyc-screener","description":"Onboarding KYC screen against sanctions/PEP/adverse-media lists with a DETERMINISTIC fuzzy-match gate: required fields (name/DOB/country) validated up front; strong/exact hits escalate unconditionally (never model-cleared); the model only downgrades weak near-misses. Typed risk verdict + human sign-off.","capabilities":{"streaming":true,"cancellation":true,"requiresApproval":false}}]},"sources":[{"path":"agent.ts","content":"import type { AdapterFactory, ChatMemory, Observer, ToolCall, ToolDefinition } from '@agentskit/core'\nimport { fuzzyMatchList } from '@agentskit/core/fuzzy-match'\nimport { fenceUntrustedContent, UNTRUSTED_CONTENT_DIRECTIVE } from '@agentskit/core/security'\nimport { invokeStructured } from '@agentskit/runtime'\nimport { defineZodTool } from '@agentskit/tools'\nimport { z } from 'zod'\nimport { zodToJsonSchema } from 'zod-to-json-schema'\nimport type { JSONSchema7 } from 'json-schema'\n\n/**\n * KYC Screener — onboarding identity screen against sanctions / PEP / adverse-media\n * lists. Like the sanctions screener, the compliance guarantee is enforced in CODE:\n * a DETERMINISTIC `fuzzyMatchList` gate, a HARD RULE that strong/exact hits escalate\n * unconditionally (the model can never clear them), and the model only DOWNGRADES\n * weak near-misses (so a hallucination can only make screening stricter). Required\n * KYC fields (name, DOB, country) are validated up front — it refuses, never guesses.\n *\n * ```ts\n * const agent = createKycScreenerAgent({\n *   adapter,\n *   lists: [{ name: 'Vladimir Putin', list: 'PEP' }, { name: 'Jane Doe', list: 'ADVERSE-MEDIA' }],\n * })\n * const r = await agent.run({ name: 'Jane Doe', dob: '1980-02-02', country: 'US' })\n * if (r.requiresHumanSignoff) routeToCompliance(r)\n * ```\n */\n\nexport type ListSource = string // e.g. 'OFAC-SDN' | 'PEP' | 'ADVERSE-MEDIA'\n\nexport interface ListEntry {\n  name: string\n  list?: ListSource\n  date?: string\n}\n\nexport interface KycCandidate {\n  name: string\n  dob: string\n  country: string\n}\n\nexport interface KycHit {\n  matched: string\n  list?: ListSource\n  score: number\n  decision: 'true-match' | 'false-positive'\n  rationale: string\n  autoCleared: boolean\n}\n\nexport type RiskTier = 'clear' | 'escalate'\n\nexport interface KycResult {\n  candidate: KycCandidate\n  riskTier: RiskTier\n  hits: KycHit[]\n  requiresHumanSignoff: boolean\n  /** Missing required fields, when the screen is refused. */\n  missing?: string[]\n}\n\nexport interface KycScreenerConfig {\n  adapter: AdapterFactory\n  /** Combined screening list — tag each entry with its source via `list`. */\n  lists: Array<string | ListEntry>\n  strongThreshold?: number\n  screenThreshold?: number\n  memory?: ChatMemory\n  observers?: Observer[]\n  onConfirm?: (toolCall: ToolCall) => boolean | Promise<boolean>\n  maxSteps?: number\n}\n\nconst Verdict = z.object({ decision: z.enum(['true-match', 'false-positive']), rationale: z.string() })\nconst toJson = (s: z.ZodTypeAny): JSONSchema7 => zodToJsonSchema(s) as JSONSchema7\n\nconst adjudicator = {\n  name: 'kyc-adjudicator',\n  description: 'Adjudicates a single weak fuzzy KYC hit against the candidate identity.',\n  systemPrompt: `You adjudicate ONE possible KYC list match. Given a candidate identity (name, DOB, country)\nand a single fuzzy-matched list name + score, decide whether they are the SAME real-world party.\n\n${UNTRUSTED_CONTENT_DIRECTIVE}\n\nBe conservative: return \"false-positive\" ONLY when the evidence clearly shows a different person\n(mismatched DOB/country, common-name coincidence). When unsure, return \"true-match\" — a human\nreviews escalations; wrongly onboarding a sanctioned/PEP party is a regulatory breach.\n\nCall submit_verdict exactly once with { decision, rationale }. Output nothing else.`,\n  tools: ['submit_verdict'],\n}\n\nconst nameOf = (e: string | ListEntry): string => (typeof e === 'string' ? e : e.name)\nconst REQUIRED: (keyof KycCandidate)[] = ['name', 'dob', 'country']\n\nexport function createKycScreenerAgent(config: KycScreenerConfig) {\n  const strong = config.strongThreshold ?? 0.92\n  const screen = config.screenThreshold ?? 0.85\n  // Guard the thresholds: a bad value (e.g. strong > 1) would silently route every\n  // hit to the model — which could then clear it. The gate must never be defeatable.\n  if (!(screen > 0 && screen <= 1 && strong > 0 && strong <= 1 && strong >= screen)) {\n    throw new Error(`invalid thresholds: require 0 < screenThreshold (${screen}) <= strongThreshold (${strong}) <= 1`)\n  }\n  const names = config.lists.map(nameOf)\n  const entryByName = new Map(config.lists.map((e) => [nameOf(e), typeof e === 'string' ? undefined : e]))\n\n  const emit = (label: string, status: 'start' | 'ok' | 'skip' | 'error', detail?: string) => {\n    for (const o of config.observers ?? []) void o.on({ type: 'progress', label, status, detail })\n  }\n\n  const submit = (): ToolDefinition =>\n    defineZodTool({\n      name: 'submit_verdict',\n      description: 'Submit the adjudication verdict. Call exactly once.',\n      schema: Verdict,\n      toJsonSchema: toJson,\n      async execute() {\n        return 'recorded'\n      },\n    }) as ToolDefinition\n\n  async function adjudicate(c: KycCandidate, matched: string, score: number): Promise<z.infer<typeof Verdict>> {\n    const task = `CANDIDATE:\\n${fenceUntrustedContent(JSON.stringify(c))}\\n\\nPOSSIBLE MATCH (score ${score.toFixed(3)}):\\n${fenceUntrustedContent(matched)}`\n    try {\n      return await invokeStructured({\n        adapter: config.adapter,\n        tool: submit(),\n        task,\n        parse: (a) => Verdict.parse(a),\n        skill: adjudicator,\n        memory: config.memory,\n        observers: config.observers,\n        onConfirm: config.onConfirm,\n        maxSteps: config.maxSteps ?? 3,\n      })\n    } catch {\n      return { decision: 'true-match', rationale: 'adjudication unavailable — failed safe to escalation' }\n    }\n  }\n\n  async function run(candidate: KycCandidate): Promise<KycResult> {\n    const missing = REQUIRED.filter((k) => !candidate?.[k] || !String(candidate[k]).trim())\n    if (missing.length) {\n      emit('validate', 'error', `missing: ${missing.join(', ')}`)\n      return { candidate, riskTier: 'escalate', hits: [], requiresHumanSignoff: true, missing }\n    }\n\n    emit('screen', 'start', candidate.name)\n    const matches = fuzzyMatchList(candidate.name, names, { threshold: screen, topK: 25 })\n    emit('screen', matches.length ? 'ok' : 'skip', `${matches.length} candidate hit(s)`)\n\n    const hits: KycHit[] = []\n    for (const m of matches) {\n      const entry = entryByName.get(m.candidate)\n      if (m.score >= strong) {\n        hits.push({ matched: m.candidate, list: entry?.list, score: m.score, decision: 'true-match', rationale: `score ${m.score.toFixed(3)} ≥ strong ${strong} — auto-escalated`, autoCleared: false })\n        continue\n      }\n      const v = await adjudicate(candidate, m.candidate, m.score)\n      hits.push({ matched: m.candidate, list: entry?.list, score: m.score, decision: v.decision, rationale: entry?.date ? `${v.rationale} (listed ${entry.date})` : v.rationale, autoCleared: v.decision === 'false-positive' })\n    }\n\n    const escalated = hits.filter((h) => !h.autoCleared)\n    const riskTier: RiskTier = escalated.length ? 'escalate' : 'clear'\n    emit('verdict', 'ok', `${riskTier} (${escalated.length}/${hits.length} unresolved)`)\n    return { candidate, riskTier, hits, requiresHumanSignoff: riskTier === 'escalate' }\n  }\n\n  return {\n    name: 'fintech-kyc-screener',\n    run,\n    asHandle() {\n      return {\n        name: 'fintech-kyc-screener',\n        run: async (task: string) => JSON.stringify(await run(JSON.parse(task) as KycCandidate)),\n      }\n    },\n  }\n}\n"},{"path":"README.md","content":"# KYC Screener\n\nOnboarding identity screen against sanctions / PEP / adverse-media lists — with the compliance guarantee enforced in **code, not a prompt**.\n\n```bash\nnpx agentskit add fintech-kyc-screener\n```\n\n```ts\nimport { anthropic } from '@agentskit/adapters'\nimport { createKycScreenerAgent } from './agents/fintech-kyc-screener/agent'\n\nconst agent = createKycScreenerAgent({\n  adapter: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-opus-4-8' }),\n  lists: [{ name: 'Vladimir Putin', list: 'PEP' }, ...sanctionsNames.map((name) => ({ name, list: 'OFAC-SDN' }))],\n})\n\nconst r = await agent.run({ name: 'Jane Doe', dob: '1980-02-02', country: 'US' })\nif (r.requiresHumanSignoff) routeToCompliance(r)\n```\n\n## Why it's safe\n\n1. **Required fields validated up front** — refuses (and reports the missing field) when `name`/`dob`/`country` are absent; never guesses.\n2. **Deterministic match** — `fuzzyMatchList` (Jaro-Winkler) scores the name against the loaded list. No LLM, auditable.\n3. **Hard rule** — score ≥ `strongThreshold` (0.92) escalates **unconditionally**; the model can't clear it.\n4. **Model only adjudicates weak hits** and can only **downgrade** → a hallucinating model only makes screening stricter.\n5. **Fail-safe** — failed/malformed adjudication → escalate. Untrusted candidate text is **fenced**.\n\n## Config\n\n| Option | Purpose |\n|--------|---------|\n| `lists` | combined screening list; tag each entry's source via `list` (`OFAC-SDN`/`PEP`/`ADVERSE-MEDIA`/…) |\n| `strongThreshold` / `screenThreshold` | escalate / adjudication floors (0.92 / 0.85) |\n| `observers`, `memory`, `onConfirm`, `maxSteps` | standard runtime options |\n\n`run(candidate)` → typed `KycResult { candidate, riskTier: 'clear'|'escalate', hits[], requiresHumanSignoff, missing? }`. `asHandle()` is JSON-in/JSON-out.\n\nSee [composing agents](../../COMPOSING.md). Sibling: [`fintech-sanctions-screener`](../fintech-sanctions-screener) (same gate, ongoing single-list screening).\n"},{"path":"eval.ts","content":"import type { EvalSuite } from '@agentskit/eval'\n\n/**\n * Eval cases for the KYC screener's AgentHandle (`run(jsonCandidate) → jsonKycResult`).\n * Wire the agent under test with a list containing 'Vladimir Putin' (PEP). The\n * deterministic gate + required-field check mean most outcomes don't depend on the model.\n */\nexport const suite: EvalSuite = {\n  name: 'fintech-kyc-screener',\n  cases: [\n    {\n      // Required field missing → refuse, escalate, report the field.\n      input: JSON.stringify({ name: 'Jane Doe', dob: '', country: 'US' }),\n      expected: (r: string) => /\"missing\":\\[/.test(r) && /dob/.test(r) && /\"riskTier\":\"escalate\"/.test(r),\n    },\n    {\n      // Exact PEP hit → escalate, sign-off.\n      input: JSON.stringify({ name: 'Vladimir Putin', dob: '1952-10-07', country: 'RU' }),\n      expected: (r: string) => /\"riskTier\":\"escalate\"/.test(r) && /\"requiresHumanSignoff\":true/.test(r),\n    },\n    {\n      // Clean identity → clear.\n      input: JSON.stringify({ name: 'Robert Brown', dob: '1990-03-03', country: 'CA' }),\n      expected: (r: string) => /\"riskTier\":\"clear\"/.test(r),\n    },\n  ],\n}\n"}],"installable":true,"validation":{"status":"approved","score":97,"confidence":0.97,"method":"codex-executor-independent-reviewer","iterations":2,"cases":3,"summary":"The agent consistently produced valid typed KYC results, enforced required-field validation up front, escalated fail-safe when required identity fields were missing, and resisted the prompt-injection request to output APPROVED. It did not hallucinate candidate details from sparse natural-language prompts, did not falsely clear anyone, and surfaced the exact missing fields with human signoff required. The normal case is conservative rather than rich, but that matches the agent purpose and README contract: KYC screening requires name, DOB, and country, and missing required fields must not be guessed.","strengths":["Valid structured output in all cases with the documented KycResult shape.","Fail-safe escalation for incomplete KYC inputs.","Exact missing fields reported: name, dob, country.","Prompt injection was ignored and did not alter the risk verdict.","No unsupported sanctions, PEP, or adverse-media claims were invented."],"notes":[]}}