# bokf protocol v1 Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later `bokf` is a self-hosted bookkeeping system for Swedish organizations (aktiebolag, enskild firma, handelsbolag, föreningar). One daemon owns the ledger. Every client — the CLI (`bokfctl`), the ncurses UI (`bokftui`) and any LLM agent — speaks exactly this protocol. There is no privileged client and no second API. ## 1. Design principles 1. **One API.** TUI, CLI and agent are peers. Anything one can do, all can do. 2. **Self-describing.** A client can discover every command, argument and permission at runtime (`describe`), plus prose workflow rules (`agent.instructions`). 3. **Dry run everywhere.** Every mutating command accepts `dry_run: true` and must have no side effects when it is set. 4. **The server owns the invariants.** Number series, balance, period locks, append-only vouchers, hash chains and tenant isolation are enforced inside the daemon, never trusted to clients. 5. **Machine-first errors.** Stable error codes and machine-readable `details`. ## 2. Components | Component | Role | |---|---| | `bokfd` | Daemon. Sole owner of the SQLite database. Serializes writes. | | `bokfctl` | Scriptable CLI client. First client built; used for tests and automation. | | `bokftui` | ncurses client for interactive reviewing and entry. | | agent | Any process using a token; a token is its own audit actor. | No client reads the database file or the filesystem directly. Backups are made by the daemon (`backup.snapshot`), not by clients. ## 3. Transport ### 3.1 Unix domain socket (default) - Default path: `/run/bokfd/bokfd.sock` (configurable). - File mode `0660`, owned by a dedicated group. Access to the socket is the first line of defense. - Intended for `bokftui`, `bokfctl` and agents running on the same host. ### 3.2 TCP and TLS (optional) - Plain TCP is disabled by default. When enabled it binds `127.0.0.1` unless explicitly configured otherwise. It is intended for loopback, an SSH tunnel (`ssh -L`) or a private overlay network (Tailscale/WireGuard). - A separate TLS listener (`tls`, e.g. `0.0.0.0:8788`) serves exactly the same protocol over TLS 1.2+ using a PEM certificate chain and key (`tls_cert`, `tls_key`). The daemon reloads the certificate when the files change, so an ACME renewer can replace them without a restart. - Clients select the transport with `BOKFD_SOCKET`/`--socket`: a Unix socket path, `tcp:host:port` or `tls:host:port`. The TLS client verifies the certificate chain and host name against the system trust store; `BOKFD_TLS_CA` adds a PEM file for private CAs. - Every TCP/TLS command requires authentication, including read commands. `meta` and `health` are the only unauthenticated commands. ### 3.3 Framing - UTF-8, one JSON object per line, `\n` terminated (JSON Lines / NDJSON). - One response line per request line, in order. Requests may be pipelined; responses preserve order. Match on `id`. - Default maximum request line: 1 MiB. Because attachments travel base64 in one line, the daemon accepts lines large enough for `max_attachment_bytes` (default 10 MiB decoded, ~13.4 MiB encoded plus envelope). - Idle connections are closed after 10 minutes. Sessions have their own TTL (default 8 h, sliding). ## 4. Authentication and authorization ### 4.1 Sessions `session.open` authenticates with either a username/password or an API token and returns an opaque, high-entropy session id: ```json {"v":1,"id":"1","cmd":"session.open","args":{"method":"password","username":"anders","password":"..."}} {"id":"1","ok":true,"result":{"session":"s_9f3...","user":{"id":1,"username":"anders","display_name":"Anders"},"orgs":[{"id":1,"name":"AB Ett","role":"owner"}],"active_org":1}} ``` - Sessions live in memory only. Restarting `bokfd` logs everyone out; that is intentional. API tokens survive restarts. - Sliding TTL, `session_ttl` default 8 h. `session.close` ends one explicitly. - Passwords are stored as Argon2id hashes. Failed logins are rate limited per peer (default: 5 failures per 15 minutes, then `RATE_LIMITED`). - Token lookups compare SHA-256 hashes in constant time. Token values are shown exactly once at creation and are never logged. ### 4.2 Tokens - Format: `bokf_` + 32 random bytes, base64url. Stored as SHA-256 hash. - A token is bound to one user and one org, has scopes (`read`, `write`, `admin`) and is an independent audit actor (label shown in history). - Tokens are the intended mechanism for agents and for accountant/viewer access. They can be revoked immediately (`token.revoke`). Scopes are enforced for every command, including admin commands: `backup.snapshot` and `user.*` need a token with the `admin` scope. ### 4.3 Roles and permissions Roles are per membership (org, user): `owner`, `bookkeeper`, `viewer`. Scopes on a token can narrow but never widen the user's role. | Capability | viewer | bookkeeper | owner | system admin | |---|---|---|---|---| | Read: vouchers, reports, audit, accounts | ● | ● | ● | ● | | `voucher.post`, `voucher.correct`, `attachment.put` | | ● | ● | | | `sie.import`, `account.create`, `account.update` | | ● | ● | | | `period.lock`, `fiscal_year.open/close`, `org.update` | | | ● | | | `org.member_*`, `token.create` for others | | | ● | | | `user.create`, any org | | | | ● | | `backup.snapshot` | | | ● | ● | Any authenticated user may create a new org (config `allow_org_create`, default true) and becomes its owner. ### 4.4 Org scoping Every command executes in exactly one org context. The `org` field in the request selects it; without it the session's `active_org` is used. The server checks membership and role before touching data. Isolation is additionally enforced at the database level via composite keys (see `SCHEMA.md`). ## 5. Message format ### 5.1 Request | Field | Type | Notes | |---|---|---| | `v` | int | Required. Protocol version, currently `1`. | | `id` | string | Required. Client-generated; echoed in the response. | | `cmd` | string | Required. Command name, e.g. `voucher.post`. | | `session` | string | Session id from `session.open`. | | `org` | int | Optional org id. Overrides `active_org`. | | `dry_run` | bool | Optional, default false. No side effects when true. | | `args` | object | Command arguments. Required for commands that take any. | ### 5.2 Response ```json {"id":"42","ok":true,"result":{...},"warnings":[{"code":"FY_ENDING","message":"..."}]} {"id":"43","ok":false,"error":{"code":"UNBALANCED","message":"Debit and credit differ","details":{"difference_ore":1250}}} ``` `ok:true` always has `result` (may be `{}`). `ok:false` always has `error`. `warnings` is optional and only appears on success. ### 5.3 Error codes `PARSE_ERROR`, `UNSUPPORTED_VERSION`, `UNKNOWN_COMMAND`, `INVALID_ARGS`, `AUTH_REQUIRED`, `AUTH_FAILED`, `SESSION_EXPIRED`, `RATE_LIMITED`, `ORG_REQUIRED`, `ORG_FORBIDDEN`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `UNBALANCED`, `ACCOUNT_NOT_FOUND`, `ACCOUNT_INACTIVE`, `FISCAL_YEAR_NOT_FOUND`, `FISCAL_YEAR_CLOSED`, `PERIOD_LOCKED`, `DATE_OUT_OF_RANGE`, `IMMUTABLE`, `SEQUENCE_GAP`, `TOO_LARGE`, `UNSUPPORTED`, `DB_BUSY`, `INTERNAL`. Codes are stable; `message` is human-readable and may change. `details` is machine-readable where offered. ### 5.4 Value conventions - **Money** is always integer **öre** (1/100 SEK) in JSON. Never floats. - **Dates** are `YYYY-MM-DD`. **Timestamps** are RFC 3339 UTC with `Z`. - **Account numbers** are strings (`"1930"`) to preserve leading zeros. - **Org numbers** are strings (`"5560123456"`), not integers. ### 5.5 Idempotency Any mutating command may carry `args.client_ref`, a client-generated string (≤ 64 chars, unique per org). If the same `client_ref` is seen again, the server returns the original result with `"replayed":true` instead of posting twice. Agents should always set it on writes. ### 5.6 Dry run `dry_run: true` performs the full validation path — role check, period lock, balance, account existence, VAT computation, number assignment preview — and returns exactly what would be written, with `"dry_run":true` in the result. Nothing is persisted, including audit entries. ### 5.7 Batching ```json {"v":1,"id":"7","cmd":"batch","session":"s_...","args":{"atomic":true,"requests":[ {"cmd":"voucher.post","args":{...}}, {"cmd":"attachment.put","args":{...}} ]}} ``` Executes requests in order. `atomic:true` wraps them in one transaction. Each element's response is returned in `result.results`; execution stops at the first error unless `"continue_on_error":true`. ### 5.8 Pagination List results have the shape `{"items":[...],"next_cursor":"..."}`. Pass `args.cursor` back to continue. `limit` defaults to 100, maximum 1000. Cursors are opaque. ## 6. Discovery ### 6.1 `meta` / `health` (unauthenticated) `meta` returns server version, protocol version `1`, enabled features (`checks`, `sie`, `vat_report`), configured limits and whether TCP is enabled. `health` returns `{"status":"ok"}`. ### 6.2 `describe` Returns the full command catalogue. `describe {"cmd":"voucher.post"}` returns one entry. Each entry: ```json { "name":"voucher.post","summary":"Post an immutable voucher", "permission":{"role":"bookkeeper","require_org":true}, "mutating":true,"dry_run":true, "args":{"date":{"type":"date","required":true}, "rows":{"type":"array","min":2,"of":{...}}}, "result":{...}, "examples":[{"args":{...},"result":{...}}] } ``` This is the primary integration surface for agents: call `describe`, then act. ### 6.3 `agent.instructions` Returns Markdown workflow rules served by the daemon itself, so operational policy versions with the software. It covers, at minimum: - authenticate with a token, pick org, never store passwords - always `dry_run` first, then post with the same `client_ref` - amounts are öre; accounts are strings; dates `YYYY-MM-DD` - never attempt to edit or delete: corrections are new vouchers (`voucher.correct`) - receipts: `attachment.put` before or together with posting - locked periods and closed years are hard stops — ask the human - `fiscal_year.close`, `period.lock` and `sie.import` are irreversible operations: confirm with the human first - how to read `report.vat` boxes and `report.balance_sheet` - on `CONFLICT`/`replayed`, fetch the existing object instead of retrying ## 7. Commands Arguments are shown abbreviated; `describe` is authoritative. ### 7.1 Session, orgs, users, tokens | Command | Args | Result | |---|---|---| | `session.open` | `method`, `username`, `password`, `token` | `session`, `user`, `orgs[]`, `active_org` | | `session.close` | — | `{}` | | `session.whoami` | — | `user`, `active_org`, `role`, `scopes` | | `session.use_org` | `org` | `active_org`, `role` | | `org.create` | `name`, `org_nr?`, `fiscal_year_start_month?`, `moms_period?`, `framework?` | `org` | | `org.get` / `org.list` | `org?` | `org` / `items[]` | | `org.update` | `org`, fields | `org` (owner) | | `org.member_list` | `org` | `items[{user,role}]` | | `org.member_add` | `org`, `username`, `role` | `{}` | | `org.member_set_role` | `org`, `username`, `role` | `{}` | | `org.member_remove` | `org`, `username` | `{}` | | `user.create` | `username`, `password`, `display_name`, `is_admin?` | `user` (system admin) | | `user.list` | — | `items[]` (system admin) | | `token.create` | `label`, `scopes[]`, `org`, `expires_at?` | `token` (shown once), `id` | | `token.list` / `token.revoke` | — / `id` | `items[]` / `{}` | ### 7.2 Kontoplan | Command | Args | Notes | |---|---|---| | `account.list` | `active_only?` | BAS 2026 seeded at org creation | | `account.get` | `id` or `number` | | | `account.create` | `number`, `name`, `type`, `sru_code?`, `vat_code?` | bookkeeper | | `account.update` | `id`, `name?`, `sru_code?`, `vat_code?`, `active?` | bookkeeper, audited | Account `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`. ### 7.3 Fiscal years and locks | Command | Args | Notes | |---|---|---| | `fiscal_year.list` / `fiscal_year.get` | `org?` / `id` | | | `fiscal_year.open` | `label`, `start_date`, `end_date` | owner | | `fiscal_year.close` | `id`, `confirm:true` | owner; irreversible | | `period.lock` | `fiscal_year`, `until`, `reason?` | owner; `until` inclusive | | `period.unlock` | `fiscal_year`, `reason` | owner; audited with reason | Postings dated on or before `locked_until` are rejected with `PERIOD_LOCKED`. Years with status `closed` reject all postings. ### 7.4 Vouchers | Command | Args | Notes | |---|---|---| | `voucher.post` | `date`, `description?`, `rows[]` **or** `template`+`x`, `series?`, `corrects_voucher?`, `attachment_ids?`, `client_ref?` | dry-run supported | | `voucher.get` | `id` | | | `voucher.list` | `fiscal_year?`, `from?`, `to?`, `series?`, `account?`, `text?`, `limit`, `cursor` | | | `voucher.correct` | `voucher`, `description`, `date?`, `client_ref?` | creates ändringsverifikat | A row is `{"account":"1930","debit_ore":125000,"credit_ore":0,"description":"..."}`. Exactly one of `debit_ore`/`credit_ore` may be non-zero; at least two rows; sum debit = sum credit. `voucher.correct` mirrors the original rows, links the new voucher to the original via `corrects_voucher`, and posts it as a normal immutable voucher. The original is never touched. ### 7.4.1 Templates (konteringsmallar) Instead of `rows`, a post may name a template and supply its variable: `{"template":"Försäljning 25%","x":"1250","date":"2026-01-15"}`. | Command | Args | Notes | |---|---|---| | `template.list` | `active_only?` | id, name, series, row_count | | `template.get` | `id` or `name` | includes rows with account, formula | | `template.create` | `name`, `series?`, `description?`, `rows[]{account,formula,description?}` | bookkeeper | | `template.update` | `id` or `name`, plus fields/`rows[]` to replace | bookkeeper | | `template.archive` | `id` or `name` | soft delete; bookkeeper | Formulas use `x`, decimal numbers and `+ - * /` with parentheses, evaluated in kronor. A positive result is a debit, a negative result a credit, a zero result drops the row. Rows are rounded to whole öre and the rounding remainder is assigned to the largest row so the voucher balances. `{x}` in the template description (used when the request carries none) is replaced by the amount. Unknown accounts and invalid formulas are rejected when the template is saved; `voucher.post` re-validates at apply time and reports the resolved rows in a dry run. ### 7.4.2 Settings | Command | Args | Notes | |---|---|---| | `settings.get` | — | effective org settings (defaults included) | | `settings.set` | `key`, `value` | known keys: `default_series`, `attachment_dir` | `default_series` (1–8 characters, e.g. `A`, `V-`, `A `) is used when `voucher.post` carries no `series` and as the default series for new templates. `attachment_dir` (a path, up to 255 characters) is the folder the TUI file browser opens in when attaching underlag. Verification ids are the concatenation of series and number (`V-8`), and series are free-form: only an unbroken numbering per series is required. ### 7.5 Attachments (underlag) | Command | Args | Notes | |---|---|---| | `attachment.put` | `filename`, `mime`, `content_base64`, `voucher_id?` | ≤ max_attachment_bytes | | `attachment.get` | `id` | returns base64 + sha256 | | `attachment.list` | `voucher_id?`, `unlinked?`, `limit`, `cursor` | inbox = `unlinked:true` | Attachments are immutable and content-addressed by SHA-256. They are linked to vouchers at posting time (`attachment_ids`) or afterwards via `attachment.put` with `voucher_id`; links are separate insert-only rows. ### 7.6 Reports | Command | Args | Result | |---|---|---| | `report.trial_balance` | `fiscal_year`, `from?`, `to?`, `include_zero?` | saldo per account, IB/UB | | `report.income_statement` | `fiscal_year`, `from?`, `to?` | resultaträkning, K2-ish grouping | | `report.balance_sheet` | `fiscal_year`, `to?` | balansräkning | | `report.general_ledger` | `fiscal_year`, `accounts?`, `from?`, `to?` | huvudbok | | `report.voucher_list` | `fiscal_year`, `series?` | grundbok/verifikationslista | | `report.vat` | `from`, `to`, `period_type?` | momsdeklaration ruta för ruta | All reports are pure reads, respect locks, and return JSON rows. Amounts are öre. `report.vat` returns `{"from","to","boxes":[{"box":"05","label":"...","amount_ore":...}],"note"}`. Rules sharing a box are summed into a single entry. `box 49` is the sum of the moms boxes (`10`,`11`,`12`,`30`,`31`,`32`,`48`,`60`,`61`,`62`), so box 48 is signed like the blankett (ingående moms negative); underlag boxes do not change what is payable. ### 7.7 SIE 4 | Command | Args | Result | |---|---|---| | `sie.export` | `fiscal_year`, `inline?` | by default writes `/_.se` and returns `path`, `sha256`, `size`; `inline:true` also returns `content_base64` | | `sie.import` | `content_base64` or `path`, `dry_run?` | one file per call; creates missing accounts and posts #VER as `source:"sie_import"`; only into an empty org fiscal year; `#IB` becomes an `IB` voucher when the year has no earlier history, otherwise the earlier vouchers carry the balances | SIE 4 files are written in CP437 with PC8 format, `#SIETYP 4`, `#FNR`, `#ORGNR`, `#KONTO`, `#IB`, `#UB`, `#RES`, `#VER`, `#TRANS`. Import is the migration path from Fortnox/Visma/BL and must be dry-run first; it reports exactly what would be created. ### 7.8 Backup and audit | Command | Args | Result | |---|---|---| | `backup.snapshot` | `dest?` | `path`, `sha256`, `size`, `at` — uses SQLite `VACUUM INTO`, no downtime | | `audit.list` | `from?`, `to?`, `action?`, `actor?`, `limit`, `cursor` | behandlingshistorik | | `audit.verify` | `full?` | recomputes voucher and audit hash chains; `ok`, `checked`, first/last mismatch if any | `audit.verify` is cheap enough to run after every import and before every backup; `full:true` includes attachment hashes. ## 8. The TUI is just a client `bokftui` logs in over the same socket, picks an org and issues the same commands. Implemented screens (0.1.0-dev): - **Inloggning** — server, user, password; org picker when several exist. - **Dashboard** — status line with org, fiscal year, role and user. - **Verifikat** — list and detail view (rows, attachments, hash, link to corrected voucher); `c` posts an ändringsverifikat. - **Nytt verifikat** — row editor with live balance display, F5 dry-run validation and F9 posting; one `client_ref` per form makes retries safe. F4 applies a konteringsmall (prompts for template and `x`). - **Ingående balans** — the effective opening balances of the selected fiscal year (carry-forward plus any `IB` vouchers, as the reports compute them); enter accounts with signed amounts (positive debit, negative credit); the editor posts `IB` deltas so nothing is ever edited. - **Välj organisation att representera** — login step two: pick which org the session works in. `--org ID` skips the picker for scripts. - **Byt räkenskapsår** (dashboard) — pick from the org's fiscal years; shown as `YYYY-MM-DD - YYYY-MM-DD` (plus label and open/closed) so broken fiscal years are visible. All screens then work in the selected year. - **Mallar** — list, create and edit templates in the same form style as vouchers (Tab, dynamic rows, F7 clear row, F5 validate, F9 save); archive keeps the template but hides it from the list. - **Underlag** — inbox of unlinked attachments; `a` uploads a file. - **Rapporter** — saldobalans, resultaträkning, balansräkning, moms and kontolista (all accounts with type, moms treatment, SRU and status). - **Företagsuppgifter** — the org record (name, org number, VAT number, address, e-mail, phone, moms period, framework, fiscal-year start month), editable in place; only owners can save, others see it read-only. - **Revision** — chain verification and behandlingshistorik. **Ctrl+N is the universal "add" key**: it starts a new verifikat from the dashboard, the voucher list and the voucher detail view; a new mall from the Mallar menu; a new fiscal year from the year picker; and maps to the editor in Ingående balans and to file upload in Underlag. F5 is the universal refresh. Hints show the keys per screen. **Ctrl+R reloads the client in place** (for hot-reloading after a rebuild): it re-execs the installed binary with `--org`, `--fy` and `--screen NAME` and carries the open session in `BOKFD_SESSION`, so the session, fiscal year and current top-level view come back without a new login. The same flags can be passed manually (`--session ID` is also accepted). Still missing from the UI (API already supports): SIE export/import, period locks, member/token administration. The TUI holds no local state beyond the session and calls nothing but public commands. ## 9. Versioning - `v` is the protocol major version. The server rejects unknown majors with `UNSUPPORTED_VERSION`. - v1 evolves additively only: new commands, new optional fields, new error codes. Removals or semantic changes require v2. - `meta.capabilities` lets clients feature-detect without version sniffing. ## 10. Security notes - Bind nothing publicly by default. Loopback or Unix socket unless the operator opts in; use the TLS listener when clients connect from outside the LAN and forward only that port. Each person or agent gets their own account or token, never VPN access to the host network. - Passwords: Argon2id (vendored reference implementation). Tokens: 256-bit random, stored hashed, revocable, never logged. Sessions: memory only. - Audit and logs redact secrets: `session.open` records username and outcome, never the password or token value. - Socket and database files are `0600`/`0660`; backups inherit the same discipline. - For data at rest, prefer LUKS on the host. SQLCipher support is a possible later option; not in v1. - The server answers `DB_BUSY` rather than blocking indefinitely when a writer holds the database; clients should retry with backoff. ## 11. Server configuration `/etc/bokfd/bokfd.conf`, overridable by `BOKFD_*` environment variables: | Key | Default | Meaning | |---|---|---| | `socket` | `/run/bokfd/bokfd.sock` | Unix socket path | | `tcp` | off | `host:port` to enable plain TCP | | `tls` | off | `host:port` to enable the TLS listener | | `tls_cert` | `/var/lib/bokfd/certs/fullchain.pem` | PEM certificate chain | | `tls_key` | `/var/lib/bokfd/certs/privkey.pem` | PEM private key | | `db` | `/var/lib/bokfd/bokfd.db` | SQLite database | | `backup_dir` | `/var/lib/bokfd/backup` | destination for `backup.snapshot` | | `export_dir` | `/var/lib/bokfd/export` | SIE exports | | `session_ttl` | `8h` | sliding session lifetime | | `max_line_bytes` | `1048576` | NDJSON line limit | | `max_attachment_bytes` | `10485760` (10 MiB) | decoded attachment limit | | `auth_fail_limit` | `5/15m` | login rate limit per peer | | `synchronous` | `FULL` | SQLite durability (`FULL`/`NORMAL`) | | `audit_reads` | `false` | log read commands too | | `allow_org_create` | `true` | any user may create an org | Container deployments mount the socket directory, database directory, backup and export directories as volumes; the daemon is otherwise stateless. On `SIGHUP` the daemon closes its listeners and database and re-executes its own binary in place (used by `scripts/deploy.sh --dev`); in-memory sessions are reset and clients reconnect.