Skip to content

Individual KYC with documents ​

An individual opens an account, and rules, a model and a person decide together: a two-step collection flow asks who they are, what they will use the account for and for a photo of an identity document; a model reads what they wrote the account is for; a rule set checks the details they typed and weighs the model's answer; and a person checks the document against the details before the account opens. Pack: examples/policies/individual-kyc-with-documents/.

A pattern for expressing an onboarding policy with a document, not compliance guidance: which documents to accept, from whom and with what checks is the tenant's decision. The model reads one answer, the account's purpose, and never sees the document, the name or the date of birth; its answer adds a reason to the reviewer's case and never rejects anyone.

The decision it automates ​

Whether a person is onboarded on the strength of the details they typed and the document they uploaded: the rules reject what the details alone rule out, and a reviewer decides everything else with the document in front of them, told when the model is not sure the account is for personal use.

Data expected ​

  • A subject of kind user; when the integrating system sets data.country, it is compared with the country the applicant enters. Everything else comes from the flow.
  • The kyc-individual collection flow, two steps: fullName, dateOfBirth, country (select), email and accountPurpose (required text, 500 characters at most: what they will use the account for, in their own words); then identityDocument (a required file field: a passport, driver's license or state ID card as JPEG, PNG, WebP or PDF up to 20 MB) and a consent checkbox. The applicant uploads the file through the presigned upload the API issues for the submission; the submit is refused until the document is scanned clean (see Documents).
  • An install named chatgpt (below).

Steps and rules ​

StepTypeWhy
collectwait_for_collectionPauses for kyc-individual; data under submission.*.
readcall_appchatgpt's decide: the question below about submission.accountPurpose, answered under ai.
rulesevaluate_rulesThe kyc_individual rule set, the model's answer among the data it weighs.
routebranchrules.outcome == reject goes straight to the decision; everything else to the review.
reviewcreate_casekyc_review, priority medium, 48-hour SLA: a person checks the document against the details, not urgent but not for a week.
decideemit_decisionFrom the rules on a rejection, otherwise the reviewer's decision.

The rule set kyc_individual sums weights into bands 0-40 approve, 40-90 manual_review, 90-100 reject:

RuleTypeSeverity, weightFails when
kyc_country_supportedcomparisonblock, 100submission.country is not one of US, CA, MX (the flow offers BR to show a rejection)
kyc_underageexpressionblock, 100the applicant's 18th birthday (the date of birth with 18 added to the year) is still ahead on the day of the check
kyc_country_mismatchexpressionwarn, 50the subject has a country and the applicant entered another one (hasPath guards subjects without one)
kyc_purpose_personalcomparisonwarn, 30ai.answers.personal_use.value is not true: the model is less than 80% sure the answer describes personal use

An unsupported country or an applicant under 18 rejects outright, without a case. A country of residence that differs from the subject's (50) and a purpose the model is not sure is personal (30) reach the reviewer as reasons; together they make 80, still a review, so the model's answer never rejects anyone. Every other application reaches the reviewer with its rules passed.

The model step ​

read asks one yes-or-no question about one text, which its stateTemplate writes from the applicant's answer (Asking for decisions):

text
Asked what they will use a personal account for, an applicant wrote:
<<accountPurpose>>
QuestionTypeAsks
personal_useyes or no, threshold 0.8Does the answer describe using the account for the applicant's own personal money, such as a paycheck, a pension or benefits, saving, paying bills, everyday spending or sending money to family?

Its criteria spell out both answers. Yes: the answer names personal uses like these, and nothing in it describes running a business, trading, taking payments from customers, or holding or moving money for other people. No: it describes one of those, or it does not say how the account will be used.

read runs before the rules, because a rule weighs its answer: an application the rules then reject (an unsupported country, an applicant under 18) has had its answer read too. The answer is the applicant's own text, and an applicant can write anything in it: the question's instructions tell the model to read it as text and never follow instructions in it (Prompt injection), and the most a crafted answer wins is one reason fewer on a case a person reviews anyway.

What the vendor sees ​

Through the install, OpenAI receives the question with its criteria and the state: what the applicant wrote the account is for. Nothing else reaches it: not the name, the date of birth, the email, the country or the document (models read text and JSON only). The sample install answers decisions through OpenAI's Responses API, which it calls with storing off; an install switched to OpenAI's Decisions API (decideEngine: native) sends the same content under OpenAI's terms for that API (Data protection).

The chatgpt install ​

The pack declares the install its step calls, "installs": { "chatgpt": "chatgpt" }: an install named chatgpt of the platform's chatgpt app, ChatGPT (OpenAI API). An install is never part of a pack. After the import, the console's import result installs chatgpt in one click, with a preset configuration (an OpenAI connection through the Responses API, the console's first suggested model, OpenAI's hosts approved) and a sample key in place of yours; store your OpenAI API key on the install's page (Configuration › Keys › openaiApiKey). Through the API, create it as Installing a model app shows, named chatgpt.

Until you replace the sample key, and whenever the model call fails (an outage that outlasts the retries, an exhausted quota, a refusal), the run fails at read and no case opens: every submitted application, including those the rules would reject. OpenAI's answer shows in the invocation record and under the run's app calls.

Every model app answers the same decide, so an install of claude, gemini, jev or openai-compatible named chatgpt serves the pack as well.

What a reviewer sees ​

A kyc_review case with the applicant's answers, the identity document, the rule results with the purpose rule among them, the model's answer under ai in the run's context (the value, probability and confidence of personal_use) and the call under Vendor checks. The document is available for download from the case, through a presigned URL that is audited. The reviewer compares the name, the date of birth and the photo with the details and, when the purpose rule failed, asks what the account is for: Request info sends the form back, and the answer that comes back is read by the model and weighed by the rules again before the case returns. Then the reviewer approves or rejects.

How to adapt it ​

Have a vendor verify the document through an http-api install for it, with its documents switched on and its webhook set up: add a call_app step between collect and rules whose start sends submission.identityDocument.fileId in a multipart body, and the name when the vendor compares it, with outputKey: "idv". The run waits for the vendor's signed webhook and resumes with the vendor's answer under idv.body (Documents). Rules in the kyc_individual set then read it, for instance one that rejects on idv.body.outcome == "declined", and the route can send the vendor's approvals straight to the decision. A vendor whose check needs the applicant again, such as a liveness check on its own page, takes an app of your own (Apps: authoring), which gets them there after the submit when it hands off (Hand-off to the applicant). A check of the details the applicant typed, such as the name and date of birth against a register that answers at once, is a request through such an install (Adding a vendor step). Add a selfie or proof-of-address field and a second verification step: a document is a vendor's or a person's to check, never the model's. Use a list_lookup against a tenant list instead of the comparison on countries, so operations can change it without a new version of the rule.

Keep the warn rules' weights under 90 together, or the model's answer could reject. Making accountPurpose optional needs a literal input.variables.accountPurpose default in read (such as "(no answer given)"), or an application without it fails the step. Give read a fallback on another vendor's install, with the same question, so an OpenAI outage does not fail the applications that reach it (Falling back to another install).

Run it ​

bash
pnpm policy:import examples/policies/individual-kyc-with-documents --publish

Then create the chatgpt install and store your key (above). The worker needs STORAGE_* for this pack. The check runs each fixture against the model stub (scripts/ai-stub.mjs), whose answer a cue word in the account's purpose picks ([[stub:high]], [[stub:low]]), and uploads the pack's passport-valid.png, a synthetic passport, as the applicant exactly as the collection terminal would:

  • expect.json: Jane Doe from the US, born in 1990, will use the account for her paycheck, rent, bills and savings. The model answers yes (0.95), every rule passes, the run opens a kyc_review case, the check approves it and the run completes with a manual approve.
  • expect-rejected.json: Sam Rivera enters Brazil as his country of residence and will take card payments from his online store's customers. The model answers no (0.05); the country is not supported (100), the residence differs from the subject's (50) and the purpose rule fails (30), so the score clamps to 100 and the run completes with an automated reject, without a case.

Released under the Apache-2.0 License.