Writing a connector
The built-in connectors (AEAT, ING, N26) are Spain-specific — your bank's export format, your country's tax return, or a service the core team doesn't personally use won't have one yet. This walks through adding your own, using N26's real, working connector as the reference.
A connector is a module (see The Store) with one extra, disclosed capability: writing to the core database. Everything it can touch is declared up front in its manifest — the Store shows exactly which tables before you ever install it.
The trust boundary
A connector is two halves that never share code, on purpose:
- Your parser (
parser.py) — pure, no SQL, no core imports. It reads a file and returns a plain list of{column: value}dicts. It cannot reach the database even if it tried: it doesn't import anything that could. - The core's ingest engine (
ingest_kit.py, generic — it has never heard of N26 or ING) validates every row against yourdatasets.yamlspec (types, bounds, required fields), checks the target table is actually one your manifest declared, and performs the write as an idempotent upsert. Your parser can be buggy or even malicious; it cannot write anything the core didn't independently validate and authorize first.
This is why adding a connector never means touching core code — you're writing a plugin against a boundary designed to not need to trust it.
The six files
apps/your-bank/
├── manifest.json # what it is, what it's allowed to touch
├── datasets.yaml # the target table's shape + validation rules (lives WITH the connector)
├── formats/
│ └── your_bank_csv.yaml # how to recognise a Your Bank export + which dataset it feeds
├── parser.py # (file, format) → list of row dicts. No SQL, no core imports.
├── gen_your_bank.py # renders the drop-zone page (connector_page.py does the real work)
├── icon.svg
└── i18n/
├── en.json
└── es.json
1. manifest.json
{
"slug": "your-bank",
"name": "Your Bank Connector",
"version": "1.0",
"updated_at": "2026-01-01",
"description": "Drop your Your Bank CSV and its transactions land in finances.",
"url": "/your-bank/",
"generator": "gen_your_bank.py",
"manifest_version": 2,
"module": { "kind": "connector", "entrypoint": "gen_your_bank.py", "output": "/your-bank/index.html" },
"connector": { "datasets": ["your_bank_movimientos"], "formats": ["formats/your_bank_csv.yaml"], "endpoint": "/ingest-api/your-bank" },
"capabilities": ["db_write_via_ingest"],
"permissions": {
"read_tables": [], "write_tables": ["fin_movements"],
"write_via": "ingest", "secrets": []
},
"owns": [], "reads": ["fin_movements"], "jobs": [], "migrations": [],
"assets": [{"path": "i18n/en.json", "kind": "i18n"}, {"path": "i18n/es.json", "kind": "i18n"},
{"path": "icon.svg", "kind": "icon"}]
}
write_tables is the actual security boundary — ingest_kit.py refuses to write anywhere not
listed here, regardless of what datasets.yaml claims.
2. datasets.yaml
Declares the shape of one row and the validation rules the core enforces before writing anything:
datasets:
your_bank_movimientos:
table: fin_movements
conflict_key: [bank, date, description, amount, balance] # idempotent upsert key
columns:
bank: {type: text, required: true}
date: {type: date, required: true, after: "1990-01-01", before: "2100-01-01"}
description: {type: text, required: true}
amount: {type: number, required: true, abs_min: 0.01, abs_max: 999999}
balance: {type: number, abs_max: 99999999}
server_set: [source, sha] # filled by the core, never by your parser
conflict_key is what makes dropping the same export twice a no-op instead of duplicate rows —
pick columns that together identify a real transaction uniquely.
3. formats/your_bank_csv.yaml
One YAML per file shape the connector recognises. Before your parser ever runs, the core's
connector_kit.py walks every file in formats/ and picks the first whose match rules fit the
dropped file (by extension, then by header/content hints) — that's how it decides which dataset the
file feeds and what to hand your parser:
id: your_bank_csv
label: "Your Bank · CSV export"
dataset: your_bank_movimientos # must match a key in this connector's datasets.yaml
match:
ext: [csv]
contains_any: # any of these header hints identifies a Your Bank export
- "Your Bank"
- "IBAN"
If nothing matches, the drop is rejected before your parser sees it. A connector with several export shapes (e.g. a CSV and a PDF-derived export) ships one file per shape here.
4. parser.py
One function per real input shape you need to detect, one function that returns the row list.
Real banks export inconsistent CSVs (delimiter, decimal separator, column names vary by locale) —
match by column content, not fixed positions. The core already decided which format matched (step
3 above) and passes it in as fmt, so dispatch on fmt["id"] rather than re-detecting it yourself:
def _parse_csv(path):
rows = []
with open(path, encoding="utf-8-sig", errors="ignore", newline="") as fh:
# ... detect delimiter, find columns by matching header names against known variants ...
# ... normalize amounts (locale decimal separator), dates (locale format) ...
rows.append({"bank": "Your Bank", "date": ..., "description": ..., "amount": ..., "balance": ...})
return rows
def parse(path, fmt):
"""(path, matched format from formats/*.yaml) -> list of {bank, date, description, amount,
balance} dicts. No SQL, no core imports."""
if fmt.get("id") == "your_bank_csv":
return _parse_csv(path)
return []
See apps/n26/parser.py (nocloud-store) for a complete real example: delimiter sniffing, a
_pick() helper that matches column headers by content across English/Spanish/German exports, and
locale-aware amount parsing (1.234,56 vs 1,234.56).
5. gen_your_bank.py
Renders the drop-zone page — this is templating, not logic:
import os, sys
APP = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.environ.get("NOCLOUD_SCRIPTS", "/opt/nocloud/scripts"))
from connector_page import render
OUT = os.environ.get("YOUR_BANK_OUT", os.path.join(APP, "site", "index.html"))
html_doc = render(slug="your-bank", title="Your Bank Connector", emoji="🏦",
endpoint="/ingest-api/your-bank", accept=".csv",
intro="Drop the transactions CSV you export from Your Bank.",
hint="Your Bank CSV · you can drop several at once")
os.makedirs(os.path.dirname(OUT), exist_ok=True)
open(OUT, "w", encoding="utf-8").write(html_doc)
6. i18n/en.json + i18n/es.json
Identity map in English ({"the exact string": "the exact string"}), translated values in
Spanish — see The Store for how installing an app adds its strings to the running site.
Testing it
Call parse() the same way the core does — path plus the matched format (its id from
formats/your_bank_csv.yaml):
python3 -c "
import sys; sys.path.insert(0, 'apps/your-bank')
from parser import parse
print(parse('sample_export.csv', {'id': 'your_bank_csv'}))
"
Then install it locally (nocloud module install your-bank or drop it in the Store UI) and drop a
real export — ingest_kit.py will reject anything that fails its datasets.yaml bounds, with the
specific row and reason, before it ever reaches the database.
Where it lives
Real connectors live in the companion nocloud-store repo (apps/your-bank/), not this one —
see that repo's README for why the split exists. nocloud-core never needs to know a specific
bank exists; the whole point of the trust boundary above is that it doesn't have to.