Indonesia-ready agents: NIK, phone, Rupiah, regions, postal codes, holidays, and PII masking.
Make AI coding agents Indonesia-ready.
Skills, an MCP server, and a privacy guard that help Claude Code, Cursor, Codex, and other agents
build apps that are correct for Indonesia, while keeping your users' personal data out of the model.
Quick start · Features · Usage · Evaluation · Limitations · Bahasa Indonesia
Ask an AI agent to "build a checkout form" and you usually get an American one:
| Without Lokal | With Lokal |
|---|---|
| State and ZIP code fields | Provinsi → Kab/Kota → Kecamatan → Kelurahan/Desa from official Kemendagri data |
Rp 1,500,000.00 | Rp1.500.000 |
| US phone validation | 0812-3456-7890 → +6281234567890, carrier detected |
| "Submit", mixed Anda/kamu | "Kirim", one consistent register |
| No QRIS, virtual accounts, or e-wallets | QRIS, Virtual Account, GoPay/OVO/DANA, with webhook-driven status |
| Customer data sent to the LLM as-is | Masked first ([NIK_1], [PHONE_1]), restored afterwards |
The second problem is easy to miss. While debugging, developers paste production logs and customer CSVs into Claude or Cursor, and national ID numbers (NIK), phone numbers, and bank accounts go straight to the model provider. Under Indonesia's personal data protection law, UU PDP (UU 27/2022), that is a real liability.
Lokal fixes both: it teaches agents Indonesian conventions, gives them accurate reference data, and puts Samaran (Indonesian for disguise) between your data and the model.
Read, Grep, cat, Get-Content, …) to a masked view.When the agent needs to look at customer data, it calls read_file_masked and sees [NAME_1], [NIK_1], [PHONE_1] instead of the real values. It can still deduplicate, join, and debug, because the same value always gets the same placeholder. When it writes results with write_file_unmasked, Samaran puts the real values back, on your machine.
Installs the skills, the MCP server, and Samaran Guard in one step:
/plugin marketplace add lokal-id/lokal
/plugin install lokal@lokal
claude mcp add lokal -- npx -y @lokal-id/mcp
Add to .cursor/mcp.json, claude_desktop_config.json, or ~/.codeium/windsurf/mcp_config.json:
{
"mcpServers": {
"lokal": {
"command": "npx",
"args": ["-y", "@lokal-id/mcp"],
"env": { "LOKAL_ROOT": "/path/to/your/project" }
}
}
}
Add to .vscode/mcp.json:
{
"servers": {
"lokal": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@lokal-id/mcp"]
}
}
}
Add to ~/.codex/config.toml:
[mcp_servers.lokal]
command = "npx"
args = ["-y", "@lokal-id/mcp"]
env = { LOKAL_ROOT = "/path/to/your/project" }
ChatGPT and Claude.ai on the web connect only to remote MCP servers over HTTPS. Run the server in HTTP mode:
npx @lokal-id/mcp serve --http --port 3000 # http://127.0.0.1:3000/mcp
npx @lokal-id/mcp serve --http --host 0.0.0.0 --port $PORT # on a Node host such as Render or Fly.io
Put it behind HTTPS (your host's domain, or cloudflared tunnel --url http://localhost:3000 to try it), then add https://<your-domain>/mcp as a custom connector: in ChatGPT through developer mode (availability depends on your plan), in Claude.ai under Settings → Connectors. HTTP mode serves the data tools only (NIK, NPWP, phone, Rupiah, regions, postal codes, holidays, QRIS). The file tools are left out: a remote server cannot see your files, and it must not open its own.
The file tools (scan_pii, read_file_masked, write_file_unmasked) can only access LOKAL_ROOT, which defaults to CLAUDE_PROJECT_DIR and then to the directory the server was started in.
npx skills add lokal-id/lokal
This installs lokal-indonesia and samaran with the skills CLI into Claude Code, Codex, Cursor, and the other agents it supports. Add -g to install them for your user instead of the current project.
Skills teach the agent the conventions. For accurate region, postal code, and holiday data and for masked file reads, add the MCP server as well (see above). Samaran Guard is a hook: it comes with the Claude Code plugin, and can be set up in Codex CLI, Cursor, and Gemini CLI.
You can also copy the folders in skills/ by hand. They use the open Agent Skills format (SKILL.md).
npm install @lokal-id/core # TypeScript / JavaScript
pip install lokal-id # Python 3.10+
The Python package is a port of @lokal-id/core with the same rules and data, verified against thousands of TypeScript outputs. It adds Decimal money, date objects, dataclass results, a logging filter for backends, and lokal.files, the MCP server's file readers, to scan an upload folder (scan_pii for your backend) or mask a PDF before it reaches an LLM. See python/.
import {
parseNik, parsePhone, formatRupiah, parseRupiah, terbilang, addWorkingDays, isWorkingDay, parseQris, setQrisAmount,
} from '@lokal-id/core';
import { listWilayah, searchWilayah, wilayahName, findByPostalCode } from '@lokal-id/core/wilayah';
parseNik('3201011508950001', { lookupWilayah: wilayahName });
// { valid: true, province: 'Jawa Barat', regency: 'Kabupaten Bogor', district: 'Cibinong',
// gender: 'L', birthDate: '1995-08-15', … }
parsePhone('0812 3456 7890');
// { e164: '+6281234567890', national: '0812-3456-7890', whatsapp: '6281234567890', operator: 'Telkomsel', … }
formatRupiah(1500000); // 'Rp1.500.000'
terbilang(1500000, { currency: true }); // 'satu juta lima ratus ribu rupiah'
parseRupiah('2jtan'); // { amount: 2000000, approximate: true }
parseRupiah('sejuta setengah'); // { amount: 1500000, approximate: false }
listWilayah('32.76'); // districts (kecamatan) of Kota Depok
searchWilayah('kab. bogor')[0].code; // '32.01'
findByPostalCode('16431'); // villages that use this postal code
addWorkingDays('2026-03-17', 1); // '2026-03-25', skipping Nyepi and Lebaran
isWorkingDay('2026-12-24', { cutiBersama: false }); // true for offices that ignore cuti bersama
parseQris(payload); // { valid, type: 'static', merchantName, nmid, criteriaLabel: 'Usaha Mikro', … }
setQrisAmount(payload, 25000); // the same merchant's QRIS, dynamic, for Rp25.000, with a new CRC
@lokal-id/core/wilayah loads about 4.4 MB of region data and runs on Node only. The main entry point does not load it and is safe for browsers.
import { Samaran } from '@lokal-id/core';
const samaran = new Samaran();
const { text } = samaran.mask(
'Nama: Budi Santoso, NIK 3201011508950001, refund ke rek BCA 1234567890, HP 0812-3456-7890',
);
// 'Nama: [NAME_1], NIK [NIK_1], refund ke rek BCA [BANK_ACCOUNT_1], HP [PHONE_1]'
const reply = await llm(text); // the model never sees the real values
samaran.unmask(reply); // placeholders are replaced with the original data
0812-… and +62 812… are both [PHONE_1].mode: 'partial' is meant for logs and admin screens: 3201********0001, 0812****7890, b***@gmail.com.exclude keeps types your app has to read. A car dealer's credit simulation needs income and installments, so new Samaran({ exclude: ['FINANCIAL'] }) leaves "gaji saya 8 juta, cicilan 3 juta" intact while NIK and phone numbers are still masked. A misspelled type name throws instead of silently masking less.Agama: Islam, NAMA : BUDI), CSV and Excel headers (agama, diagnosa, gaji, …), JSON/YAML keys and query strings (including split first_name / last_name), SQL INSERT dumps, WhatsApp chat exports, and common phrases in free text (nama saya budi, didiagnosis TBC, obat insulin, gaji saya 8 juta).| Group | Types |
|---|---|
| Identifiers | NIK (structure validated), KK, NPWP, PASSPORT, SIM, NIP, BPJS, PHONE, EMAIL, BANK_ACCOUNT, CARD (Luhn), PLATE, IP, GEO |
| Who and where | NAME, ADDRESS, BIRTH_DATE (place and date of birth) |
| General personal data | GENDER, RELIGION, MARITAL_STATUS, NATIONALITY |
| Specific personal data (UU PDP Art. 4) | HEALTH, FINANCIAL, CRIMINAL_RECORD, BIOMETRIC |
Masking by hand means every call site has to remember it. The proxy does it for the whole app: start it next to your backend and point the SDK's base URL at it.
npx @lokal-id/mcp proxy # http://127.0.0.1:8787
from openai import OpenAI
from anthropic import Anthropic
client = OpenAI(base_url='http://127.0.0.1:8787/v1') # Chat Completions, Responses, embeddings
claude = Anthropic(base_url='http://127.0.0.1:8787') # Messages
--openai https://openrouter.ai/api/v1, Groq, DeepSeek, Ollama, or Gemini's OpenAI endpoint. --exclude FINANCIAL lets the model see income figures for a credit simulation.x-samaran-masked: NIK=1,NAME=2 header. File uploads cannot be masked and are refused unless you start it with --allow-unmasked.Tested end to end with Claude Code itself running through it: the model only ever saw placeholders, and the files it wrote with its tools had the real values.
Copying production data to staging or a laptop is the most common way personal data leaks. Kembaran makes a copy in which every person is a realistic stand-in, so the data still behaves like production:
npx @lokal-id/mcp kembaran prod.sql export.csv -o staging/ --secret staging-2026
python -m lokal.kembaran prod.sql -o staging.sql # the same, from a Python backend
nama,nik,tgl_lahir,hp,email
Budi Santoso,3201011508950001,15-08-1995,0812-3456-7890,budi.santoso@gmail.com
↓
Slamet Matondang,3201012409953895,24-09-1995,0812-8655-8077,rini.pertiwi10@example.com
| Value | Stand-in keeps |
|---|---|
| NIK, KK | District, gender, and birth year; still a valid NIK |
| Birth date | The year; on the same row, the date in the stand-in NIK |
| Phone | Operator and format: 0812-… stays Telkomsel |
| Name | Number of words, casing, titles (Dr. Ir. H. …) |
| Address | RT/RW, village, district, city; street and house number change |
Nothing: …@example.com, so staging cannot mail anyone | |
| Card, bank account | Length; a card keeps its issuer prefix and passes Luhn |
0812-3456-7890 and +62 812 3456 7890 are one person. An individual's NPWP stays equal to their NIK.--secret it is random for each run; with one, a weekly staging refresh gives the same people. NIKs, phone numbers, and account numbers never collide.--keep FINANCIAL keeps more. --keep-birth-date keeps whole birth dates.anonymize_file tool does it on your machine and returns only counts. TypeScript: new Kembaran({ secret }).text(text) from @lokal-id/core/kembaran; Python: Kembaran(secret=…).text(text) from lokal.kembaran, with the same output for the same secret.A stand-in phone number can still belong to someone, so keep staging from sending SMS or WhatsApp. Text files only (SQL dumps, CSV, JSON, logs) up to 100 MB; about 2 MB/s in TypeScript.
| Tool | What it does |
|---|---|
parse_nik | Validate and decode a NIK: region, date of birth, sex |
validate_npwp | Tax IDs: 15-digit NPWP, 16-digit NPWP, or NIK as NPWP |
parse_phone | Normalize numbers, display format, WhatsApp link, carrier |
format_rupiah | Rp1.500.000, Rp1,5 jt, spelled-out amounts (terbilang) |
parse_rupiah | "2jtan", "goceng", "sejuta setengah" → numbers |
search_wilayah | Find provinces, regencies, districts, and villages with their codes |
list_wilayah | Child regions for cascading address dropdowns, with postal codes |
lookup_kode_pos | Postal code → villages, or all postal codes in a region |
list_holidays | National holidays and cuti bersama (SKB 2026, 2027) |
working_days | Business days, SLA due dates, delivery estimates |
parse_qris | Read and check a QRIS payload: merchant, NMID, criteria, amount, CRC |
set_qris_amount | Make a merchant's QRIS dynamic for an amount, with a new CRC |
scan_pii | Scan files and folders (text, CSV, JSON, logs, SQL, .xlsx, .docx, text-based PDF, up to 100 MB each) for personal data. Shows partially masked values only, and reports files and pages it could not scan (scanned or encrypted PDFs, .xls, .doc, images) |
read_file_masked | Read a file, including .xlsx sheets, .docx documents (text and tables), and PDFs (page text, tables, and filled-in form fields), with personal data replaced by stable placeholders |
write_file_unmasked | Write results with placeholders restored, on your machine |
anonymize_file | Copy dumps and exports with realistic stand-ins for every person (Kembaran), returning only counts |
Enabled automatically by the Claude Code plugin.
Prompts containing ID numbers (NIK, KK, NPWP, passport, SIM, NIP, bank account, BPJS, card) or labeled health, criminal, or biometric data are held before they are sent. Add #izinkan-pii to send anyway.
Reads of data files (CSV, Excel, JSON, logs, SQL dumps, YAML, …) that contain personal data are blocked, and Claude is pointed to read_file_masked. This covers the Read tool, Grep in content mode, and shell commands such as cat, head, grep, sed -n, awk, cut, Get-Content, and Import-Csv | Select-Object.
Photos and PDFs Samaran cannot read are asked about first. Read sends images and PDFs to the model as they are. For a photo named or filed like someone's document (ktp-budi.jpg, selfie_siti.png, anything under media/, uploads/, or storage/ except the app's own assets/ and images/) and for a scanned, encrypted, or damaged PDF, Claude Code shows a permission prompt with the reason. A PDF whose text contains personal data is blocked as a data file.
Only what would be printed counts. head -1 data.csv (the header), wc -l, grep -c, and sort data.csv > out.csv still run.
Not blocked: source code, where sample numbers are normal and the agent must be able to read them, and scripts run on data files (node process.js data.csv), which is how data gets processed without being shown to the model.
List files that contain fake data (fixtures, seeders) in .samaranignore:
test/fixtures/
database/seeders/
*.example.csv
Set SAMARAN_GUARD=off to disable the guard.
The same guard works as a hook in other agents: lokal-mcp guard recognizes each agent's events and answers in its format. Install it once so the hook starts fast (npx on every file read is slow):
npm install -g @lokal-id/mcp
.codex/hooks.json{
"hooks": {
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "lokal-mcp guard" }] }],
"PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "lokal-mcp guard" }] }]
}
}
Codex asks you to review new hooks with /hooks before they run. It reads files through the shell, so cat, sed -n, and the like are what the guard checks.
.cursor/hooks.json{
"version": 1,
"hooks": {
"beforeSubmitPrompt": [{ "command": "lokal-mcp guard" }],
"beforeReadFile": [{ "command": "lokal-mcp guard" }],
"beforeShellExecution": [{ "command": "lokal-mcp guard" }]
}
}
Cursor's file reads cannot ask first, so a photo or scanned PDF that Claude Code would ask about is held back in Cursor; list it in .samaranignore if it holds no real data.
.gemini/settings.json{
"hooks": {
"BeforeAgent": [{ "hooks": [{ "type": "command", "command": "lokal-mcp guard" }] }],
"BeforeTool": [{ "matcher": "read_file|run_shell_command", "hooks": [{ "type": "command", "command": "lokal-mcp guard" }] }]
}
}
Hooks are experimental in Gemini CLI; turn them on as its documentation describes for your version.
The same vibe-coding tasks were run in real Claude Code sessions in an empty folder, with and without the Lokal plugin, three times each (npm run eval, cases in evals/cases.mjs):
| Task | Without Lokal | With Lokal |
|---|---|---|
| Checkout form for Indonesian customers | 16/18 (address stops at city in 2 of 3 runs) | 18/18 |
| Customer-service chatbot calling the Claude API | 8/12 (data never restored in replies) | 12/12 |
| Deduplicate a customer CSV | 3/6 (NIK and phone numbers sent to the model in 3/3 runs) | 6/6 (model sees placeholders only) |
| SLA with the 2026 Lebaran holidays | 3/3 | 3/3 |
| Total | 30/39 | 39/39 |
A fifth task, debugging an app.log that contains customer NIK, phone numbers, and emails, was added later. Without Lokal, the agent ran Get-Content app.log and the whole log reached the model. With Lokal, the agent used scan_pii → read_file_masked, and no customer data reached the model in 2 of 2 runs. The first run of this task exposed a bug where a key=value log field swallowed the next pair and leaked a phone number through the scan preview. It is fixed and covered by a regression test.
Three harder tasks use answers checked against official sources rather than Lokal's own data, and real document formats with fake identities (2 runs each):
| Task | Without Lokal | With Lokal |
|---|---|---|
| Postal codes for Depok kelurahan, provinces after the 2022 Papua split, an SLA around Lebaran 2027 | 6/8 (SLA wrong in 2/2 runs: the 2027 SKB is newer than the model) | 8/8 |
| Join a marketplace export with a Midtrans log, list paid orders without a resi, phone numbers as E.164 | 8/14 (right answer, every customer's data sent to the model) | 14/14 (right answer, the model saw placeholders only) |
| Summarize a WhatsApp CS export for the product team without customer identities | 4/10 | 10/10 |
The first runs of these tasks found a bug and four detection gaps, all fixed in 0.2.1: a phone number at the end of a line leaked when the next line began with a digit, and names split into first_name / last_name, WhatsApp senders, medicines and hospital stays were missed. The id.wikipedia list of Depok postal codes turned out to be wrong for 32 kelurahan; Lokal matches en.wikipedia and other sources.
npm run eval:pii runs the format benchmark on its own: 43 of 43 personal values masked across 15 formats (KTP OCR, WhatsApp exports, Midtrans JSON, Laravel logs, SQL dumps, …), and 27 of 27 look-alikes left intact (resi numbers, invoice IDs, timestamps, call-center numbers, institutions).
This is a small sample built around everyday tasks for Indonesian developers. Do not read it as a general benchmark.
.xls and .doc, images (ID card photos, selfies), and archives are not scanned. scan_pii reports them as not scanned, never as clean, including scanned pages inside an otherwise readable PDF. The guard asks before Read sends such a PDF, or a photo that looks like someone's document, to the model; it cannot tell a KTP photo from any other picture, so it judges photos by name and folder. Files over 100 MB are reported too. For data files over 5 MB the guard checks the first 5 MB, and it reports spreadsheets, Word documents, and PDFs over 20 MB instead of parsing them, to stay within the hook's 10-second limit.Read, Grep, and common shell readers. A script that deliberately prints a data file, or something like python -c "print(open(...).read())", cannot be caught every time. The samaran skill steers the agent away from that.besok abis maghrib, lusa jam 7an, tanggal muda) with location-based prayer times.docx support in Samaran (text and tables)lokal-id) with the same results as TypeScriptHave an idea? Open an issue.
packages/core @lokal-id/core: logic and bundled data
packages/mcp @lokal-id/mcp: MCP server and Samaran Guard
python/ lokal-id: the Python port, with parity tests against TypeScript
skills/ SKILL.md files for Claude, Codex, and other agents
hooks/ Claude Code hook (bundled, no dependencies)
evals/ end-to-end evaluation against real Claude Code sessions
.claude-plugin/ plugin and marketplace manifests
Contributions are welcome, in English or Bahasa Indonesia. Missed detections and false positives are especially useful. See CONTRIBUTING.md for setup and guidelines, and never include real personal data in issues or pull requests.
npm install
npm run build # core, mcp, and the bundled hook
npm test # unit tests, MCP server end to end, guard
npm run eval # real Claude Code sessions with and without the plugin
If you find a way to get personal data past Samaran or the guard, please report it privately as described in SECURITY.md rather than opening a public issue.
Releases are built from a tag by GitHub Actions and published with Trusted Publishing and provenance. @lokal-id/core and the Python package have no dependencies, and @lokal-id/mcp bundles its own, so installing Lokal pulls in no third-party packages. SECURITY.md describes how releases are protected and how to verify what you installed.
Code is released under the MIT License. Bundled data keeps its own terms:
| Data | Source | License |
|---|---|---|
| Regions (38 provinces, 416 regencies, 98 cities, 7,285 districts, 8,496 kelurahan, 75,266 villages) | cahyadsn/wilayah, following Kepmendagri 300.2.2-2138/2025 as amended by 300.2.2-2430/2025 | MIT |
| Postal codes (83,762 of 83,762 villages) | cahyadsn/wilayah_kodepos | MIT |
| Holidays and cuti bersama | SKB 3 Menteri via setneg.go.id | Public government document |
Region totals match the Kemendagri decree exactly and were last checked in October 2026; see packages/core/data/README.md to regenerate them. Every holiday date is tested against the weekday in the official announcement. The UU PDP summary in the skills was checked against the official text of UU 27/2022.
Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
npx -y @lokal-id/mcpMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-lokal-id-lokal": {
"command": "npx",
"args": [
"-y",
"@lokal-id/mcp"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup reference@lokal-id/mcpnpmLokal works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.