Browse the docs

DocsStart here

Install Ekwo OS on your own Supabase project

Ekwo OS has no screens of its own: once installed, the books are kept through the command line, the MCP server, the REST API Supabase generates from the schema, or @ekwo-ai/core. The web application is Ekwo Cloud, the hosted edition for the people signed up on ekwo.ai.

From a free Supabase account to a first invoice

  1. Create a project at supabase.com. The free plan is enough to start. Ekwo does not create it, does not pay for it and has no access to it: it is yours from the first row.

  2. Copy two things from the dashboard:

    • Project Settings → Database → Connection string (URI). It contains your database password.
    • Project Settings → API Keys → the secret key (sb_secret_…), and the Project URL. A project created before the new keys has the legacy service_role key instead, and it works the same. These are used once, to create the first administrator in your own Supabase Auth, and are never written to disk.

    Take the pooler string, not the direct one, unless you know you have IPv6. The direct host db.<ref>.supabase.co resolves to an IPv6 address only on any recent project, so from an IPv4-only network it simply never connects. The session pooler answers on IPv4 and supports everything a migration needs:

    postgresql://postgres.<ref>:<password>@aws-1-<region>.pooler.supabase.com:5432/postgres
    

    The region is in the hostname the dashboard gives you, and so is the generation prefix, which the region does not determine: verified on a real project on 11 September 2026 in eu-west-3, where aws-1 worked and aws-0 answered "Tenant or user not found". Copy the line from the dashboard — Connect → Session pooler — rather than building it by hand.

  3. Run the installer.

    npx -y ekwo-os@latest init
    

    It asks for the connection string, the country, the chart of accounts and the language where the pack offers a choice, your organisation, the currency, the first company, the address of the first administrator and — optionally — the IBAN of your main bank account, then does the rest. Five to ten seconds on a free project. Nothing is preselected for you on the three questions whose wrong answer is expensive: the country, the chart and the language.

  4. Sign in to your project as that administrator and start booking. Until the Community web application lands, the interface is the REST API Supabase generates from the schema, or psql, or @ekwo-ai/core.

  5. Do the four things below, while the dashboard is still open. The installer prints them at the end of a successful run, because three of them are settings of your project rather than rows in your database, and nothing holding a connection string can reach them.

Everything above in one non-interactive line:

npx -y ekwo-os@latest init \
  --db-url "postgresql://postgres.YOURREF:PASSWORD@aws-1-eu-west-3.pooler.supabase.com:5432/postgres" \
  --supabase-url "https://YOURREF.supabase.co" \
  --service-role-key "$SUPABASE_SERVICE_ROLE_KEY" \
  --country BE \
  --chart default \
  --language fr \
  --org "My Organisation" \
  --company "My Company" \
  --admin-email "you@example.com" \
  --admin-password "a-long-password" \
  --fiscal-year 2026 \
  --iban "BE71096123456769" \
  --yes

--chart and --language are in that line because the Belgian pack offers a choice on both, and --yes means there is nobody to ask. See "Installing without a terminal" below.

Installing without a terminal

--yes turns off every question, and then every answer has to arrive as a flag or an environment variable. Two of them are worth knowing about before you write the script, because ekwo init refuses rather than picking one for you:

  • the chart of accounts, where the country publishes more than one. Belgium publishes two, a company chart and an association chart. Pass --chart; the refusal lists the codes the pack carries.
  • the language of the books, where the pack publishes more than one. Pass --language; the refusal lists them. The choice decides which label of the pack lands in accounts.name, and the others stay beside it in name_i18n, so it is not irreversible — but it is not a question a script should answer by accident either.

--country behaves the same way and has no default at all: the refusal names the packs the database holds. A preselected country is a chart of accounts nobody chose.

The same is true of the financial year: a pack that declares no usual opening month makes --fiscal-year-start required. Most packs name the calendar year in defaults.fiscal_year_default; one that names none — the United Kingdom's, where a company's year ends on the accounting reference date it chose — asks for the day.

ekwo company new asks the same questions about every other company, and refuses the same way.

Several countries in one installation: init --no-company

An installation is not in a country; its companies are. ekwo init loads every pack of the release whatever --country says, so one installation keeps the books of companies in as many countries as it holds packs. When there is no "first company" to name — a group, a firm, a holding with subsidiaries abroad — install without one, then create each company in its own country:

npx -y ekwo-os@latest init --no-company \
  --db-url "$EKWO_DB_URL" --supabase-url "https://YOURREF.supabase.co" \
  --service-role-key "$SUPABASE_SERVICE_ROLE_KEY" \
  --org "My Group" --admin-email "you@example.com" --yes

ekwo company new "My Company Belgium" --country BE --chart default --language nl --yes
ekwo company new "My Company France"  --country FR --language fr --yes
ekwo company list

--no-company runs the migrations, the seeds of every pack, the modules, the first administrator, init_instance() and claim_instance_admin(), and nothing else. The instance row records no country. A flag that only describes a company — --country, --company, --chart, --language, --currency, --fiscal-year, --fiscal-year-start, --vat-period, --filing-period, --iban, --bic, --bank-name — is refused with it, exit code 2, before the database is touched: it would describe nothing.

ekwo company new is create_company(), the function the create_company tool of the MCP server calls: the country pack copied in, the first financial year opened, the administrator its first owner. It is called as an administrator of the installation — --as-user, or the only one when there is exactly one — so the database judges that person exactly as it judges the tool: anybody else is refused, not_instance_admin, exit code 3. Before the call, the CLI asks what ekwo init asks about its first company, from the packs the installation holds, and refuses the same way off a terminal: no country without --country, and --chart, --language and --fiscal-year-start wherever the pack offers a choice. --currency and --fiscal-year override the pack and this year.

Without --no-company, ekwo init does what it always did, and the installation takes more companies later the same way. Neither writes a country into ekwo.json: see below.

Where table access comes from

The schema grants its own rights. Every table, view and function of Ekwo names the roles that may reach it — anon, authenticated, service_role — in the migration that creates it. ekwo doctor reads the privileges of a live database and reports a grant that is missing, a grant wider than the release declares, and a table anon can reach at all.

Two rules follow, and both are worth knowing before you change anything by hand.

anon holds no privilege on any table. The anonymous role — the one behind the publishable key your front end ships — may execute the ten helper functions row level security calls on its behalf, and nothing else. An anonymous request to a table is refused at the privilege, before any policy is read. If part of your application reads a table without signing a user in, it will stop working, and that is the intended answer: sign the user in, or grant a function deliberately.

authenticated may attempt exactly the verbs a policy of that table is prepared to judge. A grant and a policy are two halves of one sentence: a grant says which verbs may be attempted, a policy says on which rows they succeed. The reference tables a country pack fills, the tables written only by a security definer function, and the audit trail are readable and not writable — by privilege as well as by policy.

It was not always so, and the history explains a symptom you may still meet on an installation nobody has migrated. Until the migration of 14 September 2026, nothing in supabase/migrations granted table access at all. Row level security was written in the migrations in full and the underlying GRANT was not: on a Supabase project it came from that project's own default privileges on the public schema, which are there before Ekwo is. Those privileges live in pg_default_acl, keyed by the schema, so dropping and recreating public took them away — and then the reinstall succeeded, ekwo doctor reported a healthy installation, and the first read through PostgREST answered permission denied for table companies. Nothing was wrong with the schema; the grant that had never been in it was missing.

On an installation that has run ekwo migrate since, that cannot happen: the migrations put the privileges back themselves, and they take away the blanket table access the project's defaults had handed anon. Dropping public is still not something to do on a project you intend to keep — it takes your books with it. The decision and what it changed are in decision 0003.

Before you go live: four things on your project

An installation leaves four things undone, and they are undone on purpose: they are yours to decide, on a project Ekwo does not have access to. ekwo init prints this list at the end of a successful run. ekwo doctor does not check it and does not mention it — a database connection cannot see the settings of the project it is connected to.

1. Turn off self sign-up on your project. Supabase dashboard → Authentication → Sign In / Providers → "Allow new users to sign up", and switch it off. A fresh Supabase project accepts anyone who posts an e-mail address and a password to its authentication endpoint, which is the right default for a public application and the wrong one for a set of books. An Ekwo installation is closed: the people who keep the books are invited to it. Row level security means a stranger who signs up sees nothing — they are a member of no company — but they are a row in auth.users that nobody asked for, on a project whose sign-up endpoint is open to the internet.

2. Keep two administrators. An instance administrator is what claims the instance and invites everybody else. With one, a lost password, a closed mailbox or a person on holiday is a set of books that nobody can let anyone into. Create the second account in your Supabase Auth and add it with claim_instance_admin(), or invite it from the application once it is signed in.

3. Keep the service_role key off every machine that does not need it. It is not a powerful user: it is the absence of a door. A request carrying it bypasses row level security entirely and reads every company in the instance. This CLI reads it from a flag, an environment variable or a masked prompt, uses it once to create the first account, and writes it nowhere — see Secrets. Anywhere else it sits, it sits as a copy of your whole ledger. --admin-user-id installs against an account that already exists and needs no key at all.

4. Read DISCLAIMER.md before you file anything. DISCLAIMER.md, at the root of the repository. A country pack is a reading of a country's rules at the date of its version, and its golden test proves that the pack agrees with itself — not that it agrees with the law. ekwo init prints the certification status of the pack it installs for the same reason. The books are yours, in every country where you file.

None of these is an action Ekwo performs on your project, now or later. The project is yours from the first row: the settings are yours to change, the key is yours to hold, and what you file is yours to answer for.

Automatic verification of the first three is a phase 1 question, and it is not free: they are answered by the Supabase management API, so checking them means handing ekwo doctor a management token, and a token that can read a project's settings can change them. Until that trade is worth making, the list is printed and read by a person.

What init does, step by step

Step What happens Why it is done this way
1 Applies supabase/migrations/*.sql in order Recorded in supabase_migrations.schema_migrations, the Supabase CLI's own history table, so supabase db push and ekwo migrate stay interchangeable
2 Applies every reference seed of supabase/seed/, in file-name order: 00_currencies.sql, 00_territories.sql, 05_framework_generic.sql, then one <n>_pack_<cc>.sql per country pack, in the order of the number each pack declares The currencies, the territories the tax rules name, the country-less financial statements every chart falls back on, and every country pack of the release — so a company in any of them can be created later without installing anything. They are exactly the seeds supabase/config.toml lists (a list ekwo pack build writes from packs/), so supabase db push installs the same set; a test compares both paths row by row. 90_demo_company.sql is sample data and is never applied here
3 Creates the first administrator through the Supabase Auth admin API See below: a database connection cannot be a signed-in user
4 init_instance(), claim_instance_admin(), the company, company_members as owner, install_country_template(), the first financial year, and the bank account when an IBAN was given The six steps of the root README, in the same order, plus the one thing nobody can derive
5 Writes ekwo.json The installation: project URL and schema version. Nothing else, ever — no country, since each company carries its own. A file written by 0.6 or earlier also names a country; it is still read, and the key is taken for nothing
6 Asks whether to register with Ekwo The default answer is no, and no is a supported answer forever

Every step checks before it acts. Running ekwo init twice on the same project reports what was already there and creates nothing a second time.

Why the first user goes through Supabase Auth

Every row level security policy in the schema compares auth.uid() against a row, and auth.uid() reads the JWT of the request. The CLI holds a Postgres connection, not a session: it runs as the database owner, auth.uid() is NULL, and row level security is bypassed rather than satisfied. So the installer cannot be the first user. It can only create one and then write the rows that user will be recognised by.

Creating that user in SQL is not an option either. auth.users belongs to GoTrue — the password hash, the confirmation state, the identity row — and writing it by hand produces an account that looks right and cannot sign in. Hence the order: the admin API first, its user id second, instance_admins and company_members third.

This is the only reason --service-role-key exists. Pass --admin-user-id instead if the account already exists, and no key is needed.

Rendered from packages/cli/README.md, in the repository, when the site was built. Read or change it on GitHub