# Quickstart > **Every command below has been run, start to finish, from an empty database.** > The API, worker and parser processes were started against a real Postgres and > a real Qdrant; `init` created the organization; and each request on this page > was issued in order — create a layer, issue a grant, ingest, poll the job to > `indexed`, search as a granted user and get the chunk, search as a user > without the grant and get nothing. Revoking the grant then removed it from the > results while the points were still in the index. Four bugs were found doing > it and are fixed. > > **`docker compose --profile minimal up` has now been run too**, from a clean > clone, and it found three things: the image could not build at all (pnpm > refuses to replace `node_modules` without a TTY, and a build never has one), > `.env.example` defaulted the embedding endpoint to a service that only the > `full` profile starts, and the vector store was pinned to a version its own > client warns about on every boot. All three are fixed. ## What you will need - Docker with Compose v2 - 8 GB of RAM for the `minimal` profile - An embedding endpoint. Any OpenAI-compatible one will do; the `full` profile brings its own — emulated on arm64, see the note directly below. > **On arm64, `--profile full` and `--profile demo` run their embedders — and > `full`'s reranker — emulated.** All three are `text-embeddings-inference`, > published for linux/amd64 and nothing else, so `docker-compose.yml` names > `platform: linux/amd64` on them: the amd64 image is pulled and run under > emulation rather than the pull failing with > `no matching manifest for linux/arm64/v8`. Everything else in this stack is > arm64 and native. For the two-minute section below that means the seed's > embedder (`demo-embedder`) runs emulated on an M-series Mac — it works, and > it takes noticeably longer than the same command on x86-64. > > Nothing to select and no second file — the commands on this page are the same > on an M-series Mac, an arm64 node and an x86-64 server. Emulation does work on > Docker Desktop with Rosetta and it is slow, the reranker sitting near a full > core at idle, so [apple-silicon.md](./apple-silicon.md) has the faster > arrangement: `minimal` with an embedder on the host. That is a choice about > speed, not a different way of starting the stack. The caveat is stated on the bullet as well as in the note on purpose: "the `full` profile brings its own" was true of x86-64 and read as unconditional, and an architecture-blind sentence three lines above an architecture-specific one is how a reader ends up running the profile that cannot start. ## Two minutes, nothing configured If what you want is to see it work, start here. `--profile demo` brings its own embedder and seeds itself, so there is nothing in `.env` to fill in and no endpoint to supply — copy the example and generate the one secret every process needs before it will start. `NACRE_JWT_SECRET` ships empty on purpose: a signing key with a default in the source is a key anybody reading the source can forge tokens with, so there is no value to leave in place, only one to generate. ```bash git clone https://github.com/nacre-work/nacre && cd nacre cp .env.example .env printf 'NACRE_JWT_SECRET=%s\n' "$(openssl rand -base64 32)" >> .env docker compose -f docker-compose.yml -f docker-compose.images.yml pull docker compose -f docker-compose.yml -f docker-compose.images.yml --profile demo up -d docker compose --profile demo logs -f demo-seed ``` The seed creates an organization, three layers, an example corpus and **three people whose grants differ** — then prints their credentials, because this API generates passwords and refuses to accept one, so nothing here can hardcode them. Sign in at `http://localhost:8082` as each of the three and ask the same question: ``` what is the contract number for Northwind ``` The administrator finds `NW-2026-0431`. The engineer and the contractor find nothing — not a shorter list, nothing. The permission filter runs inside the index traversal, so for them the query never reached the document. That is the whole product in one query. When you want to point it at your own documents, carry on below. **The engineer and the contractor are marked `shared`**, because a demonstration prints its logins for anybody to use. Such an account has no `/v1/me` credential surface — it cannot enrol a second factor, change its own password, or be sent a reset link — since a credential several people hold has no one person for a factor to belong to, and the first holder to enrol one would lock out every other with no route back: an administrator deliberately cannot remove somebody's second factor. Its password is still reset by an administrator, which is how a published credential is rotated. If you publish a login of your own, create it with `nacre users create you@example.com --shared`, or tick *Several people will hold this password* in the console's New user dialog. Either way the choice is fixed at creation: clearing it on an account whose password is already out would reopen that surface to whoever holds it. Reset it with `docker compose --profile demo down -v`. The model is English-only and the corpus is fiction; a real installation picks its own embedder, which is what the rest of this page is about. ## First run ```bash git clone https://github.com/nacre-work/nacre && cd nacre cp .env.example .env ``` **Now set `NACRE_JWT_SECRET` and `NACRE_DEFAULT_EMBEDDING_ENDPOINT` in `.env`.** Both ship empty on purpose and every process refuses to start without them, naming the variable. The signing secret is not a value to pick — a key with a default in the source is a key anybody reading the source can forge tokens with — it is one to generate: ```bash printf 'NACRE_JWT_SECRET=%s\n' "$(openssl rand -base64 32)" >> .env ``` `minimal` starts no embedder — that is what keeps it runnable on a laptop without a GPU — so the endpoint is a value only you can supply: ```ini # any OpenAI-compatible /embeddings endpoint NACRE_DEFAULT_EMBEDDING_ENDPOINT=http://host.docker.internal:8000 ``` On `--profile full` — emulated on arm64, per the note above — use the embedder that profile brings: `http://embedder:80`. No GPU and no machine to spare? Route the model to a hosted vendor instead — `--profile hosted` starts an adapter that speaks to Cloudflare Workers AI, the Gemini API and others, and [config.md](./config.md#two-hosted-arrangements-end-to-end) carries the two worked arrangements with their models and widths. Whichever you pick, `NACRE_DEFAULT_EMBEDDING_DIM` has to match the model or the index is built with the wrong width and every search misses. Then: ```bash docker compose --profile minimal up -d ``` ### Or without building anything The command above builds every Nacre service from this checkout, which is right for working on it and slow for meeting it: a pnpm install and a TypeScript build stand between you and a product you have not seen. A release publishes four images and they pull anonymously, so there is a shorter way: ```bash docker compose -f docker-compose.yml -f docker-compose.images.yml pull docker compose -f docker-compose.yml -f docker-compose.images.yml --profile minimal up -d ``` Or, once, so the rest of this page's commands stay as written: ```bash export COMPOSE_FILE=docker-compose.yml:docker-compose.images.yml ``` **`pull` is not ceremony.** The overlay names images and Compose still carries `build:` from the base file, so `up` on a host that has never pulled would build instead of failing — the whole point lost, silently, and four minutes later. `NACRE_VERSION` in `.env` pins all four images. Unset, they are `latest`, which is what somebody trying the product wants and what nobody running one should have: `latest` moves under the next `pull` and afterwards there is no way to say what was running. Everything below is the same either way. The images are the ones the Helm chart deploys, so what you try here is what you would run. `minimal` starts the API, a worker, PostgreSQL, Qdrant, Redis, and the parser sidecar, and expects embeddings from an endpoint you name in `NACRE_DEFAULT_EMBEDDING_ENDPOINT`. Use `--profile full` to run the embedder and the reranker locally too; see [config.md](./config.md) for the difference. **`--profile` is additive and can be given more than once.** The profiles are not four deployments to pick between: each one adds services. Embeddings from a vendor's API on a stack that also wants MinIO is both names, not the newer one: ```bash docker compose --profile full --profile hosted up -d ``` Worth knowing before it costs you an afternoon: **a service in a profile you did not name is left alone, even if it is running.** Compose does not mention it. So `--profile full up -d` on a stack whose `hosted` adapter came up earlier updates everything except that adapter, reports success, and gives no sign — which is indistinguishable, from the outside, from the adapter being broken. ## After startup — what listens where **One address: `http://localhost:8082`**, up the moment `docker compose up` returns. Everything a person or an agent talks to is under it. | Path | Surface | For | |---|---|---| | `/` | **Admin UI — your organization** | a person: search, layers, grants, service accounts | | `/v1` | **API** — REST | apps, the SDK — every `curl` on this page | | `/mcp` | **MCP** | agents (Streamable HTTP) | | `/oauth`, `/.well-known` | the authorization server and discovery | how an MCP client gets a token without one being pasted | | — | **Global admin — every organization** | commercial (`admin-global`); not in this build | The `web` service is what makes that true: it serves the static `packages/admin` bundle and proxies the rest to the process behind it. Same origin is not a convenience — the API sends no CORS headers on purpose, so it is the only arrangement that works without a header the API does not send. It is also what the Helm chart's ingress does, so the shape here is the shape in production rather than a local simplification. The **global admin** — organizations, quotas and the default embedding model across the *whole* installation — is the one screen that is commercial. The community UI is scoped to the single organization in your token and has no way to name another; managing many is `admin-global`, in the enterprise build. The API and MCP are **also** published directly, on `8080` and `8081`, and both still work — `http://localhost:8080/v1/...` and `http://localhost:8081/mcp` are the same processes without the proxy in front. Postgres, Qdrant, Redis and the parser are internal to the Compose network and deliberately stay that way. If 8080, 8081 or 8082 is already taken on your machine, set `NACRE_API_HOST_PORT` / `NACRE_MCP_HOST_PORT` / `NACRE_WEB_HOST_PORT` in `.env` — the host side moves, the ports inside the network do not. **If the stack is not on the same machine as the people and agents using it** — a VPN address, a LAN name, anything but `localhost` — **one** value in `.env` says so: ```bash NACRE_CANONICAL_URL=http://10.0.0.5:8082 ``` That is the front door, and everything else is derived from it or from the request: the OAuth issuer, the address the discovery document hands a client, the consent redirect the browser follows, and the resource identifier the MCP transport builds from the `Host` each client used. Leave `NACRE_OAUTH_CONSENT_URL` and `NACRE_MCP_CANONICAL_URL` unset. It was three values before this shape, on three ports, and each failed at a different step of the same flow with a different unhelpful message — one of them by sending the *browser* to the operator's own machine. `NACRE_JWT_ISSUER` is a different thing despite the name, and is **not** one of the addresses that has to resolve: it is the `iss` claim, compared against itself at verification and never fetched. Changing `NACRE_CANONICAL_URL` does not invalidate a token already issued. ## The first organization Nothing exists yet — not an organization, not a user, not the Qdrant collection. One command creates all of it: ```bash docker compose run --rm api node packages/api/dist/init.js \ --org acme --email you@example.com --name "Acme" ``` It prints the workspace id, an administrator token valid for one hour, and a password: ``` Organization acme is ready. Workspace id a2ebffde-29ac-4327-9891-a51f40edce0c Admin user you@example.com An administrator token, valid for one hour: export NACRE_TOKEN=eyJhbGciOiJIUzI1NiJ9… And a password for signing in, which is not printed again: … ``` Run it twice and the second run changes nothing — it reports what already existed, and **it does not reset the password**. That matters more than it sounds: a first install that dies halfway leaves exactly the state you would otherwise have to unpick by hand, and a re-run cannot lock an administrator out of their own installation. The token is signed with `NACRE_JWT_SECRET`, printed to a terminal and probably into a shell history. It exists to get you through this page, which is why it expires in an hour. **When it does, sign in for another.** The password above is the one that lasts: ```bash curl -sX POST http://localhost:8082/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"you@example.com","password":"…"}' ``` ```jsonc { "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 900, "refresh_token": "…" } ``` `POST /v1/auth/refresh` exchanges the refresh token for a new pair, and the old refresh token stops working the moment it does — replaying one revokes the whole family, because by then the legitimate holder has already exchanged it and there is no way to tell which of the two holders is genuine. See [api.md](./api.md#signing-in). ## The same page, with one command instead of four Everything from here to the first search is four `curl` invocations, and they are written out below because they are the contract and because an integrator wants to see the requests. If what you want is the *result*, there is a client for it: ```bash npx @nacre.work/cli login --url http://localhost:8082 npx @nacre.work/cli layers create handbook --name Handbook npx @nacre.work/cli ingest ./docs --layer handbook npx @nacre.work/cli search "when do new hires get access" ``` It signs in with the password `init` printed rather than the hour-long token, keeps the session in `~/.config/nacre/config.json` at mode `0600`, resolves a layer slug to the id the API wants, walks a directory, and waits for every document to reach `indexed` — reporting the ones that did not **and exiting non-zero**, which the rest of this page cannot do for you. `NACRE_API_URL` and `NACRE_TOKEN` override the stored session, which is what a service account in a pipeline uses; there is no terminal there to log in from. The requests below are what it sends. ## A layer, and someone allowed to read it A document lives in a layer, and a layer lives in a workspace. Nothing is readable by default — there is no implicit grant to a creator, an owner, or an administrator of the workspace, which is deliberate and is the thing most permission systems do differently. ```bash curl -X POST http://localhost:8082/v1/layers \ -H "Authorization: Bearer $NACRE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "workspace_id": "…", "slug": "handbook", "name": "Handbook" }' ``` `init` prints the workspace id, and `GET /v1/workspaces` is where to find it afterwards — that endpoint exists because it is the one id the rest of the API cannot give you: ```bash curl -s http://localhost:8082/v1/workspaces -H "Authorization: Bearer $NACRE_TOKEN" ``` It lists the workspaces you can reach, which is not the same as every workspace in the organization. `POST /v1/workspaces` creates another, and takes `org_admin` — there is no scope above a workspace to hold a grant on. A slug already in use answers `409`; a workspace you may not administer answers `404`, the same as one that does not exist. Then grant someone `read` on it: ```bash curl -X POST http://localhost:8082/v1/grants \ -H "Authorization: Bearer $NACRE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "principal_type": "user", "principal_id": "…", "scope_type": "layer", "scope_id": "…", "permission": "read" }' ``` Both need `admin` on the scope in question — on the workspace to create a layer in it, on the layer to grant against it. Admin on one layer does not let you grant yourself another. A caller without it gets `404`, the same answer as for a scope that does not exist, because telling the two apart is how you enumerate what exists. Grants are allow-only here. To revoke, delete the grant; `effect: deny` and `scope_type: document` are commercial and this build refuses them with a `400` saying so. `write` does not imply `read` — someone with `write` on a layer can put documents in it and cannot search them. `admin` implies both. This is the opposite of most systems and it is not an oversight; see [authz.md](./authz.md). ## First document ```bash curl -X POST http://localhost:8082/v1/documents \ -H "Authorization: Bearer $NACRE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "layer": "handbook", "external_id": "onboarding-2026", "title": "Onboarding", "content": "New engineers get repository access on their first day." }' ``` Ingest is asynchronous and returns `202` with a `job_id`. Poll `GET /v1/jobs/{id}` until `status` is `indexed`. ## First search ```bash curl -X POST http://localhost:8082/v1/search \ -H "Authorization: Bearer $NACRE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "query": "when do new hires get access", "top_k": 5 }' ``` You get back exactly the chunks your token is permitted to see. Not the top 5 filtered down to whatever survived — 5 permitted results, because the filter is applied inside the index traversal. If that distinction is new, read [authz.md](./authz.md); it is the reason this project exists. ## The same thing in a browser Everything above has a screen — search, layers, grants, service accounts and the access log, for one organization — and the stack already serves it. Open [http://localhost:8082](http://localhost:8082). ![The sign-in screen, with tabs for a password and for a pasted token](./assets/admin/sign-in.png) Sign in with the address and password `init` printed. The session renews itself, so it outlives the hour that token above has. **Leave `API` as it is** — it is already the origin serving this page, which is what keeps the browser's requests same-origin. `ORGANIZATION` stays empty unless one address has accounts in more than one of them. Pasting the token works too, on the second tab, and so does a service account key — which is how you look at exactly what an agent can see, from the other side of this page. ### Layers, and what a fresh installation looks like This is the screen a new installation actually shows, and the first step nobody guesses: ![The layers screen on a fresh install: no layers, an explanation, and a New layer button](./assets/admin/layers-empty.png) **A layer comes first.** Documents are ingested into one and grants are issued on one, so until there is a layer there is nothing to ingest into and nothing to grant on — which is why search on a new installation returns nothing and cannot say more than that. The heading says "no layers **you can read**" rather than "no layers", and that is deliberate rather than cautious: the catalog is permission data, so a fresh installation and a token with no grant reaching anything have to look the same from here. Both are worth the same sentence. ![The layers list, with two layers and their document counts](./assets/admin/layers.png) Deleting one takes its documents with it, so the confirmation asks for the slug rather than for a click — a grant re-issues, and a layer's documents do not come back. It needs `admin` on the layer's workspace, never `write`: an ingest-only service account is exactly the principal that holds `write` into a layer and must not be able to remove it. ![The delete-layer confirmation, asking for the slug to be typed](./assets/admin/delete-layer.png) `New layer` asks for the workspace, and the picker is filled from the ones this token may administer — there is exactly one after `init`, and it is selected already. The id below it stays editable, for pasting the one `init` printed. ![The new-layer dialog, with the workspace picker showing one workspace](./assets/admin/new-layer.png) ### Search The screen that answers the question this project exists for: **it runs as the token you signed in with**, with no administrative bypass. What you see here is what the same credential gets over REST and over MCP — so signing in as a service account is how you check what an agent can actually reach. ![Search results, two permitted chunks with their layer and score](./assets/admin/search.png) The count says *permitted results*, not *results*, and the difference is the whole design: the filter runs inside the index traversal, so `top_k` returns k things you may see rather than k things trimmed down to whatever survived. ### Grants, and service accounts ![The grants list, showing a service account and a group grant](./assets/admin/grants.png) Issuing a grant asks for a principal, a scope and a permission. The scope shortcut follows the scope type — layers when the scope is a layer, workspaces when it is a workspace — so an id is something you can paste rather than something you have to know. Two rules surprise people, and the screen says both rather than assuming: `write` does not imply `read`, and a deny beats an allow at any depth. ### People — onboarding a colleague `init` makes one administrator, and until this screen existed everybody else had to be inserted into `users` by hand. Nothing about the model required that: `grants.principal_type` has admitted `user` and `group` since the first migration. ![The people screen, with three users and two groups](./assets/admin/people.png) ![The new-user dialog, with the role picker and what a new member reaches](./assets/admin/new-user.png) Creating a user generates a password and shows it once — it is not accepted as input, because a password an administrator chose is one they know, and an argument ends up in a shell history. Losing it is a reset, not a recovery. ![The password, shown once, with a copy button and a deliberate confirmation](./assets/admin/new-user-password.png) The same treatment the service account key gets, and for the same reason: it is stored as a scrypt hash, so there is no path from this screen that recovers it. **A new member reaches nothing.** The role decides whether they can administer the organization, never what they can read: that is entirely the grants, which is why an `org_admin` who has issued themselves no grant sees an empty search. ![A group's direct members: one user and one nested group](./assets/admin/group-members.png) Grant the **group** rather than the person, wherever there is more than one of them. Adding somebody to a group gives them everything it holds and removing them takes it back on their next request — the permitted set is computed per request, so there is nothing to wait for. Granting each person individually is what leaves an ex-colleague with access nobody remembers to revoke. Disabling a user stops sign-in and refresh, and keeps the row: the access log names it. It does **not** revoke their grants, and an access token already issued keeps working until it expires — `NACRE_ACCESS_TOKEN_TTL` is that window. ![The service accounts list, with key prefixes and last use](./assets/admin/accounts.png) A key is shown once, when the account is made. What is stored is a hash, so it cannot be read back from the API, the database or a backup — losing one means minting another, which is the trade for a key that a leaked backup does not carry. ### The access log ![The access log: filters for action, result and a date range, and four records with their actor, target and result](./assets/admin/audit.png) Every grant issued, every account created and every document read, newest first. This is `GET /v1/audit` — the same endpoint `nacre audit` reads, and the same one that serves JSONL and CSV by content negotiation for anything larger than a screen. Reading it is itself recorded, as `audit.read`, so a visit here appears in the next page. There is no field for an actor's id, because nobody knows a uuid and a mistyped one returns an empty log — which reads as *nothing happened*. Pressing an actor narrows to that actor instead, and a chip beside the filters clears it. A `platform_admin` sees this screen too and sees a **different log**: administrative actions, never the record of who read what. That is rule 2 in [authz.md](./authz.md) applied to the journal, set by the server from the role and not by anything a caller can send. See [audit.md](./audit.md). ![The Security screen: a password form, and two enrolled factors — an authenticator app and a security key — with the recovery-code count under them](./assets/admin/security.png) **Security** is the caller's own account and never anybody else's. Changing a password takes the current one and signs out every other session — including the other browser somebody left signed in — while this one carries on with a fresh token. It needs nothing configured and is the recovery path every installation has; a link by email needs `NACRE_SMTP_URL`. **A second factor comes in two kinds and the panel draws the ones that work.** A security key needs nothing configured — its relying party is `NACRE_CANONICAL_URL`, which is already set — so an installation with no `NACRE_2FA_KEY` offers that and not the authenticator app: ![The Security screen offering a security key only: one enrolled key, and a single Add a security key button](./assets/admin/security-keys-only.png) The kinds come off the server rather than being assumed, which is why the two screens differ by one button rather than by a message. An installation that offers neither — no key and no canonical URL — is what this looks like, and the password form is still there, because the two panels are independent: ![The same screen with neither kind available: the password form, and a panel explaining why enrolment is not offered](./assets/admin/security-no-key.png) There is deliberately no administrative counterpart. An administrator resets a password and cannot touch anybody's second factor — one they could enrol or remove would be a thing the account's administrator holds rather than a thing the person holds. The screen is `packages/admin`, a static bundle the `web` service serves on its own origin while proxying `/v1` to the API — so the browser makes same-origin requests and there is no CORS to configure, which is the point rather than a detail: the API sends no CORS headers on purpose. In production the arrangement is the same, one origin fronting the bundle and `/v1`; `docker/nginx.conf.template` is the proxy this stack uses and a working example of it. The UI can also be pointed at a different API from its sign-in screen, which is a real deployment and a slightly worse one — same-origin is what avoids the header the API declines to send. ## Connecting an agent Over MCP, Streamable HTTP — this is the `mcp` container from the table above: ``` http://localhost:8082/mcp ``` In production the same endpoint lives behind the API's host rather than on its own one — the ingress routes `/mcp` there, so an agent is configured with `https://api.example.com/mcp` and never needs to know there is a second process. Locally, over STDIO, with a service account key. Mint one first — the token `init` printed expires in an hour and is not a credential for an agent: ```bash curl -X POST http://localhost:8082/v1/service-accounts \ -H "Authorization: Bearer $NACRE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "local-agent" }' ``` The `key` in that response is the only time it exists outside your terminal — it is stored hashed, so it cannot be read back from the API, the database, or a backup. Lose it and you mint another. Then: ```bash set -a && . ./.env && set +a # local mode is the server, in-process NACRE_SERVICE_KEY=nacre_sk_… npx @nacre.work/mcp ``` The first line is not optional. Local mode has no HTTP hop — it connects to Postgres, Qdrant, Redis, the parser and the embedder itself — so it needs the same configuration the server has. Started without it, the process refuses and lists what is missing rather than half-working. It also means the agent holds your database credentials, which is the trade for no network hop, and why anything that is not a laptop should use the Streamable HTTP transport instead. A new service account can reach nothing at all until you grant it something — it is a principal like any other, so give it `read` on a layer exactly as you would a user. The local mode carries exactly those permissions. There is no developer-convenience relaxation, and there will not be one. `DELETE /v1/service-accounts/{id}` revokes a key, and it stops working on the next request: unlike a token this credential has no expiry of its own, so revocation is the only thing that ever ends it. ## The one thing to get right before production The OAuth issuer and `/.well-known/*` belong on `api.nacre.work`, not on the apex domain. Static hosting on the apex intercepts the discovery path, and the issuer URL is baked into every token you have ever issued — moving it later breaks every client at once. [mcp.md](./mcp.md) has the detail.