Skip to content

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 your datasets.yaml spec (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.