Configuration as code
Three YAML files describe an operator.
An operator's whole setup is three kinds of YAML file, kept in its own repository. Everything else, such as sessions, receipts and statements, the system derives from them.
The organization
Who the operator is: the name, numbers and address on every receipt, and how drivers pay.
manifest: cpms/v1
kind: organization
organization:
key: example
name: Example Charging AB
org_number: "000000-0000"
vat_number: SE000000000001
address: "Exempelgatan 1, 123 45 Exempelstad"
email: info@example.com
payment_mode: direct # drivers pay into your own Stripe account
currency: SEK
display_timezone: Europe/Stockholm
operator_id: "SE*EXA" # eMI3: your OCPI party and the prefix of every EVSE id
keyis the stable name that the other documents use. Every object has one, so renaming something never breaks a reference.payment_mode: directmeans that drivers pay into the operator's own Stripe account, so the operator is the seller.operator_idis needed once a site is published over OCPI; every EVSE id is derived from it.
The price list
One for the whole organization. A tariff is the price per kWh, a plan is the offer, and a channel says which plan a driver gets.
manifest: cpms/v1
kind: pricelist
organization: example
tariffs:
- key: standard
name: Standard
kind: flat
amounts: incl_vat # the price incl. VAT is exact
vat_rate: "0.25"
prices: # dated rows, oldest first
- {from: 2026-01-01, per_kwh: "4.50"}
- {from: 2026-12-01, per_kwh: "4.70"}
plans:
- key: dropin
name: Drop-in
tariff: standard
requires: none # anyone who scans the QR code
preauth_amount: "300" # reserved on the card, incl. VAT
- key: roaming
tariff: standard # roaming drivers pay the same per kWh
requires: roaming
channels:
adhoc: [dropin] # what a driver without an account gets
roaming: {default: roaming} # drivers from roaming partners
amounts: incl_vatsays which side of the price is exact. The other side is calculated, so 4.70 incl. VAT stays 4.70 on the receipt.- Prices are dated rows. A price change is a new row with its date, and a row that has taken effect cannot be edited, so old receipts stay explainable.
- Drop-in and roaming share one tariff, so they pay the same and a price change is one line.
- Amounts are quoted strings, never floats. A float is not money.
A site
One file per station, with what the installer knows and nothing commercial: a site never names a price.
manifest: cpms/v1
kind: site
organization: example
site:
key: exempelgatan
name: Exempelgatan 12
address: Exempelgatan 12
city: Exempelstad
postal_code: "123 45"
coordinates: {latitude: "59.0000000", longitude: "18.0000000"}
publish: true # listed in OCPI, e.g. Nobil
fuse_groups:
- key: main
name: Main fuse
physical_fuse_amps: 63
budget_amps: 50 # the balancer stays within this
charge_points:
- key: cp1
ocpp_id: SIM-0001
phase_rotation: [L1, L2, L3]
outlets:
- {connector: 1, key: cp1-1, plug: type2_socket, phases: 3, max_amps: 32}
- {connector: 2, key: cp1-2, plug: type2_socket, phases: 3, max_amps: 32}
- key: cp2
ocpp_id: SIM-0002
phase_rotation: [L2, L3, L1] # wired one phase over
outlets:
- {connector: 1, key: cp2-1, plug: type2_socket, phases: 3, max_amps: 32}
- {connector: 2, key: cp2-2, public: false} # not offered to drivers
- key: cp3
ocpp_id: SIM-0003
phase_rotation: [L3, L1, L2]
outlets:
- {connector: 1, key: cp3-1, plug: type2_cable, phases: 3, max_amps: 16}
budget_ampsis what the load balancer may use. It can be set belowphysical_fuse_amps, for example for a smaller grid subscription, but never above it.phase_rotationis set per charge point. Boxes are often wired with the phases rotated so that single-phase cars spread over L1–L3, and the balancer needs to know which station phase each box draws from.publish: truelists the site over OCPI, for example in Nobil. A published site must then give its address, coordinates and, for each public outlet,plug,phasesandmax_amps.- An outlet with
public: falseis not offered to drivers. The files contain no secrets: charge point keys are generated by the server.
How a change goes live
The same two commands work for all three kinds of file, as with infrastructure-as-code.
cpms manifest plan site.yamlshows what would change and any warnings, such as sessions that are running now. It checks the file against the organization's other documents: a budget above the fuse, a plan naming a missing tariff, or an edited price row is an error.cpms manifest applyapplies exactly what was planned, with a message saying why. If the document changed on the server since the plan, it refuses, and you plan again.- Nothing is removed unless you pass
--prune. - Every applied version is kept with its message and author:
cpms manifest historylists them.
The command line is on PyPI as leancpms-cli:
uv tool install leancpms-cli # or: pipx install leancpms-cli cpms login --url https://cpms.example.com
cpms login opens your browser, where you log in to the portal and approve the key.
There is a JSON Schema for the files, so editors can validate and autocomplete them as you type. It comes with the command line: cpms manifest schema. cpms manifest validate checks a file offline.
$ cpms manifest plan exempelgatan.yaml example site exempelgatan: version 3 ~ site.fuse_groups[main].budget_amps: 50 → 63 + site.fuse_groups[main].charge_points[cp4] ! fuse group exempelgatan/main has 2 active sessions; new budget applies next cycle priced by price list version 2: 6 public outlets → plan dropin, 4.50 SEK/kWh incl. VAT $ cpms manifest apply exempelgatan.yaml -m "New grid subscription, one more box" … apply? [y/N] y applied example site exempelgatan version 4 $ cpms manifest history example --site exempelgatan VERSION APPLIED (UTC) AUTHOR MESSAGE v1 2026-03-02 08:41:10 key:lcp_4f2a:cli First three boxes v2 2026-05-19 13:05:47 key:lcp_4f2a:cli cp2 is wired L2-L3-L1 v3 2026-08-11 10:22:03 key:lcp_4f2a:cli Outlet cp2-2 reserved for the property v4 2026-11-02 09:14:31 key:lcp_4f2a:cli New grid subscription, one more box
Your files, your repository
It works the same whether you self-host or use LeanCPMS Cloud. Keep the files in your own git repository, review changes as pull requests, or let an AI agent propose them.