accounted
← Blog

Building on an Open Source Swedish Ledger

Accounted is AGPL-licensed Swedish double-entry bookkeeping: 280+ REST endpoints, 150+ MCP tools, 900+ structured error codes, and a Docker self-host path. This is the developer tour: the stack, how to run it yourself, how API keys and scopes work, and what the validation layer refuses to let an agent do.

For developersMay 12, 2026Last updated: September 27, 20267 min read

TL;DRAccounted is AGPL-licensed Swedish double-entry bookkeeping: 280+ REST endpoints, 150+ MCP tools, 900+ structured error codes, and a Docker self-host path. This is the developer tour: the stack, how to run it yourself, how API keys and scopes work, and what the validation layer refuses to let an agent do.

This is the developer entry point if you want to build with accounted rather than evaluate it from outside. Self-hosting, the validation layer, how MCP and REST relate, and what you get out of the box. It assumes you've read The Accounting API for the AI Era once.

The stack

No exotic choices. That's deliberate — the interesting part of an accounting system should be the accounting, not the infrastructure.

┌──────────────────────────────────────────────────────┐
│  Clients (Web UI / Claude / Cursor / your backend)   │
└──────────────────────────────────────────────────────┘
              │                       │
              │ REST                  │ MCP
              ▼                       ▼
┌──────────────────────────────────────────────────────┐
│         Next.js 16 route handlers (App Router)       │
│  - API-key scopes, OAuth 2.1, idempotency cache      │
└──────────────────────────────────────────────────────┘
              │
              ▼
┌──────────────────────────────────────────────────────┐
│           Deterministic validation layer             │
│  - Debit/credit balance                              │
│  - BAS chart + account existence                     │
│  - Fiscal period state machine                       │
│  - Swedish VAT rules                                 │
└──────────────────────────────────────────────────────┘
              │
              ▼
┌──────────────────────────────────────────────────────┐
│      Postgres (Supabase) — RLS, triggers, pg_cron    │
│      Append-only event log + journal entries         │
└──────────────────────────────────────────────────────┘

Next.js 16, React 19, Postgres via Supabase. Everything that writes goes through validation; the event log records what happened.

Self-hosting

Prerequisites: Docker with Compose v2, and a Supabase project (the free tier works).

git clone https://github.com/erp-mafia/accounted.git
cd accounted
./docker/setup.sh

docker/setup.sh checks prerequisites, prompts for your Supabase credentials, generates CRON_SECRET and writes .env. You'll need the project URL, anon key, and service-role key from your Supabase project's Settings → API.

Then apply the schema:

npm install -g supabase
supabase link --project-ref <your-project-ref>
supabase db push

The migrations enable uuid-ossp, pgvector (created by an early migration; nothing stores embeddings today), btree_gist (fiscal period overlap prevention), and pg_cron (scheduled jobs). pg_cron needs a paid Supabase plan: on the free tier that migration fails, and you skip it. The cron sidecar container does the same work over HTTP instead.

Two things to know about self-hosted deployments: the Docker image sets NEXT_PUBLIC_SELF_HOSTED=true, which disables MFA enforcement (users can still enable TOTP themselves), and you should configure your own SMTP provider rather than relying on Supabase's built-in email rate limits.

docker compose up -d starts two containers: the app (ghcr.io/erp-mafia/gnubok:latest) and a cron sidecar. Full instructions live in docs/SELF-HOSTING.md in the repo.

API keys and scopes

Keys are minted in the dashboard at /settings/api. They're prefixed gnubok_sk_live_* for normal use and gnubok_sk_test_* for simulation: a test key forces every write into dry-run and refuses writes that can't be simulated, but its reads return real company data. There is no separate sandbox.

Scopes are per capability, not per role. There is no admin scope that grants everything. A sample:

ScopeGrants
transactions:readList transactions, template and category suggestions
transactions:writeCategorise, uncategorise, receipt matching, invoice linking
reports:readChart of accounts, ledgers, balance sheet, VAT, KPI, SIE
bookkeeping:writeClose/lock periods, opening balances, year-end, SIE import
skatteverket:writeFile VAT and AGI (stages; signed with BankID)
pending_operations:approveApprove or reject staged operations
webhooks:manageCreate, list, update, delete webhook subscriptions

A key created with no explicit scopes gets a read-only default set. OAuth grants start from what the client asks for: a client registered under Settings gets the scopes it requested pre-ticked (read scopes if it asked for none), while built-in clients such as Claude and ChatGPT get every scope the user's role allows. Any row can be unticked on the consent screen.

Stage-and-approve on one key is blocked by default. It's a segregation-of-duties control: a key holding both a staging scope and pending_operations:approve could stage a posting and commit it with nobody else reviewing it, so that combination is refused unless the user explicitly acknowledges it (acknowledge_sod: true). The acknowledgement is stored on the key, with who and when. For Claude and ChatGPT the consent click is that acknowledgement, which is what makes approving in chat possible. A key can also carry an unattended commit limit in SEK: above it, a human approves at /pending.

What the validation layer refuses

Every write is checked before anything commits:

  1. Balance: total debits equal total credits.
  2. Account existence: every line's account is in the chart of accounts.
  3. Period state: the target period is open. Locked periods reject writes.
  4. VAT consistency: VAT treatment matches account type; reverse-charge cases flagged.
  5. Idempotency: a repeated Idempotency-Key header replays the original response instead of double-booking. Most write endpoints require one.

Failures come back as one of 900+ structured error codes, each carrying a machine-readable code, a docs URL and, where there's a known fix, a recovery hint. That last part matters more than it sounds: an agent that gets recovery_hint back can usually fix its own call without a human reading the error.

Most write endpoints also support dry-run (?dry_run=true or an X-Dry-Run header), so you can validate a write without performing it. Not all of them: check the endpoint's docs rather than assuming.

The MCP tool surface

150+ tools. The common ones are listed when a client connects; the rest are one search away. Grouped by domain:

  • Transactions — list, categorise, match against invoices, receipt matching.
  • Invoices — customer ledger, create, send, mark paid, credit.
  • Supplier invoices — inbox processing, approval, crediting.
  • Reports — trial balance, income statement, balance sheet, KPI, dimension P&L.
  • VAT — report generation, close checks, declaration filing.
  • Periods — open, lock, close, year-end orchestration.
  • Payroll — salary runs, payslips, AGI generation, vacation liability.
  • Audit and migration — SIE import/export, audit package, voucher gap explanations.

Rather than memorising them, call gnubok_search_tools to rank capabilities by query, or gnubok_list_skills and gnubok_load_skill to get step-by-step workflows for things like month-end close or a VAT declaration.

Pending operations

Every MCP write that touches the books stages a pending operation rather than committing. The shape, abridged, for a categorised transaction:

{
  "staged": true,
  "operation_id": "7d2f0c4e-5b1a-4e8f-9c3d-2a6b8e1f4c90",
  "risk_level": "low",
  "message": "Staged as pending_operation 7d2f0c4e-… (risk: low). …",
  "preview": {
    "debit_account": "6212",
    "credit_account": "1930",
    "amount": 499,
    "currency": "SEK",
    "date": "2026-04-14"
  },
  "approve": { "args": { "operation_id": "7d2f0c4e-…" } }
}

You approve in chat or at /pending. Operations carry a risk level (low, medium, high) that reflects what's at stake — categorising a transaction is not the same as locking a period or booking a salary run.

Worth being precise about one asymmetry: staged writes are MCP-only. The REST endpoint POST /api/v1/companies/{companyId}/transactions/{id}/categorize creates the journal entry directly. If you're writing backend code, you own the review step yourself; the staging model is there for agent access, where the human isn't reading every call.

Agent auto-commit was removed in May 2026. A proposal is booked only when an approve call comes from a credential that holds the approve scope, and a scheduled, unattended run is refused if it tries.

REST and MCP

Which one you use depends on who's driving:

  • REST for code you write. Scheduled jobs, webhook handlers, your own product features.
  • MCP for LLM-driven work. Claude, Cursor, agents that orchestrate multi-step operations.

Both hit the same validation layer and produce the same audit trail. Neither is a second-class surface.

There are no official TypeScript or Python SDKs — the API is plain REST with an OpenAPI 3.1 spec at https://app.accounted.se/api/v1/openapi.json, and llms.txt / llms-full.txt if you'd rather point a coding agent at it and let it generate the client.

What you do next

Evaluating? Mint a gnubok_sk_test_* key and connect Claude in read-only mode. Twenty minutes to see what agentic access to a ledger actually feels like.

Integrating? Read the API documentation, issue a scoped key, and start with GET /api/v1/companies. Build one small thing that works before expanding.

Embedding? See Embedded accounting for SaaS for the integration shape, including company provisioning over the API. Commercial terms for platforms and agencies start at /byraer.

Contributing? Open an issue on GitHub. Non-trivial changes benefit from agreeing the direction first.

The accounting category needed an API-first contender. The interesting thing isn't accounted itself — it's what becomes possible once bookkeeping is treated like any other piece of backend infrastructure: open, scriptable, and made of primitives instead of products.

Frequently asked

Why AGPL instead of MIT?
AGPL means anyone who runs a modified version of the core as a service has to publish those changes under the same licence, so improvements to the ledger flow back. Extensions that use only the documented Extension API are exempt by an explicit exception in LICENSE and can be proprietary. The MCP bridge packages are MIT: they're glue, not the ledger.
What database does it need?
Postgres, via Supabase. The schema, RLS policies, triggers, and functions all ship as ordered migrations in supabase/migrations. Some migrations need pgvector, btree_gist, and pg_cron. There is no SQLite path.
How does the validation layer work?
Every write runs through deterministic checks before commit: debits equal credits, accounts exist, the target period is open, VAT treatment is consistent, and a repeated Idempotency-Key replays the original response. Failures return one of 900+ structured error codes with a docs link and, where there's a known fix, a recovery hint. No LLM sits in the validation path.
Can an agent book something without me?
Only if you give it that right. Every MCP write that touches the books stages a pending operation, and booking it takes a separate approve call. A credential holding both a staging scope and the approve scope is blocked unless you explicitly acknowledge the combination, and the acknowledgement is recorded on the key. For Claude and ChatGPT the consent click is that acknowledgement. A scheduled, unattended run can stage but never approve.
How do I contribute?
Open an issue on GitHub for bugs and feature requests, and open one before starting non-trivial work so we can align on direction. PRs welcome: run `npm test` first, and sign off every commit (DCO, `git commit -s`).
Next

Start building.

REST + MCP. Open source under AGPL. SIE4 in, SIE4 out. Self-host or use the managed version.