Note
Under development, not in production. It runs locally, with Ansattporten and Altinn mocked in development mode. See Status before you rely on it.
genki is a shared platform for generative AI services in the Norwegian public sector, from Digdir. An agency describes a service in a short definition: what it is for, which documents it may search, which model answers, and where people can reach it. The platform runs it, with sources, access control, cost tracking and quality checks built in.
It provides:
- A headless API that runs every service the same way, whether it is called as an OpenAI-compatible model, an MCP tool, an A2A agent or over REST
- Three web apps on that API: the portal, where an organisation builds, tests and publishes services; the workspace, where employees use them; and the console, where Digdir runs the platform
- Definitions instead of code: a service, variant, prompt, dataset or guardrail is versioned configuration, and every change is audited with who made it and why
- Datasets and ingestion on Typesense, pgvector, Postgres, HTTP or MCP, with reranking
- Quality checks: test sets, judges and publishing gates, plus a parity harness that compares answers with digdir-headless-rag
genki means generative AI in two languages:
- Norwegian: Gen + KI (kunstig intelligens, "artificial intelligence") is generative AI.
- Japanese: 元気 (genki) means energetic, lively, healthy.
- 元 (gen) is the root or source: the models and human knowledge.
- 気 (ki) is energy: the potential they unlock.
Note
The code uses the plain English genai prefix, for example @genai/client and GENAI_DEV_MODE.
| Doc | Use it for |
|---|---|
docs/development.md |
Running locally in detail: ports, dev mode, test persons, secrets, tests, package layout |
docs/api/README.md |
The HTTP API: sign-in, rights, errors, and a guide per area |
docs/build-a-frontend.md |
The shortest path to your own frontend |
docs/frontend-contract.md |
Every field and rule a frontend relies on |
docs/bring-your-own-logic.md |
A tool step or a whole engine you run yourself |
docs/architecture/system-atlas.html |
Every module, what it does and what it talks to. Open it locally in a browser |
docs/adr/ |
Architecture decisions. Start with 0001, 0023 and 0031 |
CONTRIBUTING.md |
Branches, checks and pull requests |
apps/: the API, the worker, the portal, the workspace, the console, andparity(compares answers with headless-rag)apps/api/config/local/: the definitions the API publishes at startup: services, flows, models, prices, datasets, test personspackages/: the kernel, definitions, ports, providers, strategies, channel adapters and platform services, each behind a contract (layout)docs/: the API guides, frontend contract, ADRs and the System Atlasinfra/: the Azure setup, written but parkedscripts/: setup, running everything locally, and building the atlasmise.toml: the pinned Node version
You need mise and Docker. No secrets are needed to get a working stack: login, rights and a test model are all local.
mise trust
mise installThis installs Node 24, pinned in mise.toml. Run mise trust once per clone: without it, mise ignores mise.toml and you get whatever Node is on your PATH, and setup stops if that is older than 24.
npm startThis runs two steps, which you can also run on their own:
npm run setupinstalls dependencies, starts Postgres (with pgvector) in Docker on127.0.0.1:55432, and creates.env.localwith a local database, generated keys and the local test model. It is safe to run again: an existing.env.localis never changed, only checked, and setup names the settings it lacks.npm run dev:allstarts the API, portal, console and workspace in one terminal, with prefixed output. At startup the API runs database migrations and publishes the definitions inapps/api/config/local.
Docker must be running before you start, or setup stops at Postgres. If a port is already taken, dev:all refuses to start and names the port; docs/development.md shows how to move every port.
| Address | App | For |
|---|---|---|
| http://localhost:5176 | Workspace | Employees: chat with published services, with sources |
| http://localhost:5173 | Portal | Organisations: build, test and publish services |
| http://localhost:5174 | Console | Digdir operators: tenants, models, shared services |
| http://localhost:8080/reference | API reference | Developers: every operation, searchable |
Each app sends you to a mock Ansattporten with a list of test persons. Pick one:
| Person | Use it for |
|---|---|
| Kari Testansatt | Every right in Digdir: start here |
| Ola Drift | Platform operator, for the console |
| Ingrid Berg | Limited rights: use services, datasets, usage |
| Erik Larsen | Rights in other organisations |
| Hensynsløs Måke | No access |
Rights come from a mock Altinn. The test persons are in apps/api/config/local/identity.yaml.
The mock login only exists in development mode (GENAI_DEV_MODE=true), which npm start turns on. Without it, the API refuses to start until a real database, login provider and Altinn are configured.
In the workspace, pick Lokal testchat and write a message. It answers with what you wrote. That is on purpose: the local test model echoes, so you can try login, chat, conversations and feedback without a real model or any cost.
In the portal, open Tjenester (Services) and choose Ny tjeneste (New service). Describe what the service is for, pick a model, try it in the playground, and publish it. Publishing gives it a version number and records who published it and why. It then shows up in the workspace for the people it is offered to.
On a fresh clone the only model is the local echo model, so every answer repeats the question. That is enough to try the whole path from draft to published service.
Everything the apps do goes through the same API. Setup put an API key in .env.local as DEV_API_KEY:
curl -s http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $DEV_API_KEY" -H 'Content-Type: application/json' \
-d '{"model":"local-test-chat__default","messages":[{"role":"user","content":"Hei!"}]}'See Build on it for the other channels.
The example services, such as Kunnskapsassistenten, Klarspråk and Nynorsk, need real models and search. Without those settings they are skipped and logged at startup, so Lokal testchat is the only service until you add them.
| Works on a fresh clone, no secrets | Needs settings in .env.local |
|---|---|
| Login with test persons, rights, portal, console, workspace | Real models: FOUNDRY_OPENAI_*, FOUNDRY_CLAUDE_* |
| Postgres storage, migrations, API keys, conversations, audit | Kudos and public-docs search: TYPESENSE_* |
| Lokal testchat, and your own services on the local model | Reranking: COLBERT_API_*; Langfuse, Maskinporten, real Altinn |
Every setting is listed in .env.example, with what happens when it is empty. Restart after you change .env.local.
Press Ctrl-C in the terminal running npm start. That stops the API and the apps. Postgres keeps running in Docker and keeps your data, so starting again picks up where you left off. To stop Postgres too:
docker compose downTo throw the database away as well and start from nothing:
docker compose down -v| Part | Role |
|---|---|
| Definitions | What each service is, in versioned configuration. Every change is audited with who made it and why. |
| Channels | Turn each protocol into one kind of request. A service exposed on several channels behaves the same on all of them. |
| Kernel | Runs the published version of a service. Every call is a task with an append-only log, and access, quotas and budgets are enforced there. |
| Backends | Models, search engines and tools sit behind ports, so a provider can be swapped in configuration. |
| Tenants | Organisations. Rights come from Altinn, and data is isolated with row-level security in Postgres (ADR 0022). |
Digdir runs the platform and chooses the models. Your organisation adds its data and builds services. People and systems use the services wherever you offer them, and their feedback goes into the next version.
The platform uses five nouns, the same in the portal, the API and the docs (ADR 0031).
| Word | Norwegian | What it is |
|---|---|---|
| Service | Tjeneste | What people and integrations pick, such as Kunnskapsassistenten |
| Variant | Variant | Another way to use the same service: a faster model, search only, a specialised flow |
| Way of working | Arbeidsmåte | How a variant answers: a ready-made strategy with settings, or a step flow |
| Step | Steg | One step in a step flow |
| Building block | Byggekloss | Something in the catalogue: a model, a dataset, a tool or a skill |
In the portal, each variant shows its way of working as plain steps, so a service owner can see what it does without reading configuration:
A service is drafted and tested in the portal, then published as a numbered version. Once published, employees use it in the workspace, and every answer is marked as made with AI, with numbered sources and a way to give feedback:
Publishing checks the definition against the catalogues. These are refused:
- unknown models
- a model outside the service's data residency
- a strategy configuration its schema refuses
- duplicate public names
Every published version is kept, with who changed it and why.
Kunnskapsassistenten answers questions with sources from public reports in Kudos. It is one example service, defined like any other in apps/api/config/local/services. Shortened here; the full definition also has example questions, accepted input and retrieval settings:
apiVersion: genai/v1
kind: Service
metadata: { tenant: '991825827', id: kunnskapsassistenten, name: Kunnskapsassistenten }
spec:
purpose: Svar med kilder fra offentlige rapporter i Kudos
audience: [employees]
riskClass: limited
variants:
- id: default
- id: mini
title: { nb: Rask (GPT-5 mini), en: Fast (GPT-5 mini) }
model: foundry-openai/gpt-5-mini
- id: retrieve-only
title: { nb: Bare søk, en: Search only }
chat: false
strategy: { id: rag-single-shot, version: 1, config: { generate: false } }
output: { kind: answer, withSources: true }
strategy:
id: agent-loop
version: 1
config: { budget: { searches: 4, reads: 6, readChars: 12000 }, temperature: 0.3 }
knowledge:
datasets: [{ dataset: digdir/kudos }]
reranker: colbert
model: { primary: foundry-openai/gpt-4o-2m-tps, residency: global }
exposure: { chat: true, mcp: true, openai: true, rest: true, a2a: true }Three variants, one service:
| Variant | Does |
|---|---|
default |
Searches by itself |
mini |
Answers faster on a smaller model |
retrieve-only |
Only searches |
Important
A frontend should show this as one service with a variant picker, never as three services.
| Change | How |
|---|---|
| A new service, variant, prompt, dataset choice or guardrail | Definition, in the portal or YAML, versioned with a reason |
| A variant with its own flow of steps | Step flow, picked as the variant's way of working |
| A new model, an OpenAI-compatible vendor, prices | models.yaml, prices.yaml |
| A dataset on another search engine | knowledge.yaml (ADR 0025) |
| A tool from an MCP server, an OpenAPI spec or an A2A agent | Tool connection, then the service's tools |
| A new strategy, step type, search engine or protocol | Code: one package behind a contract (ADR 0023) |
The platform is headless: the portal, console and workspace are clients of the same API you can use.
%%{init: {'sequence': {'mirrorActors': false}}}%%
sequenceDiagram
autonumber
participant FE as Your frontend
participant API as Platform API
FE->>API: GET /v1/models
API-->>FE: services, variants, datasets, forms
FE->>API: GET /v1/models/{id}/schema
API-->>FE: input form and output as JSON Schema
FE->>API: POST /v1/chat/completions (stream)
API-->>FE: answer with [1] [2] and numbered sources
FE->>API: POST /api/feedback
| Channel | Routes |
|---|---|
| OpenAI-compatible | GET /v1/models, POST /v1/chat/completions |
| MCP | POST /api/mcp, one tool per variant |
| A2A | GET /a2a, agent cards, POST /a2a/:agent |
| REST | /api/datasets, /api/facets, /api/conversations, /api/feedback |
| Administration | Everything else, documented in /openapi.json and at /reference |
Authentication:
- A system sends an API key as
X-API-KeyorAuthorization: Bearer. Keys are made in the portal, under Tilgang (Access). A Maskinporten token works instead of a key when Maskinporten is configured. - A person signs in with Ansattporten, and their rights come from Altinn for each organisation.
Details are in docs/api/README.md.
Where to start
- Build a frontend: the shortest path.
- Frontend contract: every field and rule. Two frontends were built from the published contract alone, with no access to the repository (ADR 0030).
- Bring your own logic: a tool step or a whole engine you run yourself.
npm run checkThis runs prettier, eslint, the type check, dependency boundaries and every test. Docker must be running: the Postgres, pgvector and Typesense-writer suites start containers with Testcontainers. Run a part with npx vitest run <paths>. Tests never call real models; the live suites skip themselves unless their settings are set.
CI runs the same check, a secret scan with gitleaks and a Docker image build.
The API reads .env.local, and nothing else. Secrets in definitions are referenced as secret://env/<name>, which reads the environment variable <NAME>: foundry-openai-key reads FOUNDRY_OPENAI_KEY.
Definitions live in the database once published. The files in apps/api/config/local are a starting point: at startup the API publishes the ones that do not exist yet, and leaves the rest alone. Change a published service in the portal or through the API, not by editing its file.
The platform runs locally only. The Azure setup in infra/ is written but parked, and the Dockerfile builds the API image that CI checks.
- Setup says Node is too old. Run
mise trustin the repository, thenmise install. An untrustedmise.tomlis ignored without an error. - Setup stops at "Starting Postgres". Docker is not running, or port 55432 is taken. Start Docker, or move the port as shown in docs/development.md.
- "Ports already in use". Another copy, or something else, holds one of the app ports. Stop it, or give this copy its own ports.
- "Login will fail: add these callbacks". You moved an app to a port the mock login does not know. Add the callback it names to
apps/api/config/local/identity.yaml. - The API refuses to start and names missing settings. It is not in development mode.
npm startturns it on; to run the API alone withnpm run dev, addGENAI_DEV_MODE=trueto.env.local. - Only Lokal testchat shows up. The other services need model and search settings. The API log names each service it skipped and why.
| Area | Where it stands |
|---|---|
| Running | Locally only. The Azure setup in infra/ is written but parked. |
| Login and rights | Ansattporten, Maskinporten and Altinn are wired behind settings, mocked in development mode. |
| Replacing headless-rag | Every capability of digdir-headless-rag is covered or a written difference in ADR 0017. On a draft test set nobody has reviewed yet, the judge score shows no significant difference (platform minus headless-rag: -0.03, 95% CI -0.09 to 0.03; report). |
| Kudos search | Reads the shared index headless-rag keeps up to date. The platform only searches it. |
| Decisions | Each ADR says whether it is built, and names what is missing if not. |
Contributions are welcome.
- Read CONTRIBUTING.md.
- Run
npm run checkbefore you open a pull request. - Report vulnerabilities privately as described in SECURITY.md, never in a public issue.
Everyone taking part follows the Code of Conduct. genki is released under the MIT licence.


