Full documentation: neverbot.github.io/nottario.
Open source, self-hosted coordinator for human developers and their AI agents. One instance brings three things under a single source of truth: a task backlog with cycles, named priority buckets, dependencies and atomic claim semantics; a versioned markdown store for skills, specs and team notes; and an architecture diagram of services and their connections that agents maintain themselves. Humans browse it in a web UI; agents drive it through MCP.
Per-project bearer tokens make multi-agent work safe by
construction: a token scoped to project A is rejected the moment it
touches project B, admin or not, and tasks.claim_next hands a task
to exactly one caller even when two agents race for it. Drop the
MCP into Claude Code or Cursor and the agent can list, claim,
deliver, link commits and update the architecture diagram without
humans relaying state by hand.
The whole server is a single Go binary in one container, talking to
whatever Postgres you already run. The web UI updates live as agents
and humans work — no refresh — and the binary takes its own daily
backups so that's not a separate piece of infrastructure to remember.
Self-host on a VPS behind your reverse proxy, point your agents at
the MCP endpoint with one claude mcp add per project, and the team
stops losing track of who's doing what.
Agents don't need a manual brief: every instance ships an embedded
skill bundle that teaches them the conventions — whoami first,
the carry-on loop (claim → work → link commits → close), one task
per role, when to touch the architecture diagram. They pull it on
first connect, so the rules and the server's behaviour are always in
sync. Instances can override any file to tighten the rules for a
specific team.
Kanban — the pickup surface. Tasks grouped by state, tagged with
type, named priority bucket and target role. Cards that have an
assignee show the owner's avatar in the bottom-right corner — at a
glance you see who's holding what. Humans grab a card with a click;
agents take the same row atomically via nottario.tasks.claim_next,
so two of them running in parallel never land on the same task. The
sprint header summarises throughput without leaving the view.
Gantt — the planning surface. No calendar dates: Nottario sorts
tasks by dependencies (left-to-right is what blocks what) and by
priority bucket inside each layer. The NOW line marks live work,
dashed boxes are pending, solid grey is done. Hovering a pill
reveals the full title in place. Helps humans see the critical path;
helps agents (and tasks.next) reason about what is genuinely ready
to pick up versus what is still waiting on a precondition.
Shared markdown context — versioned project knowledge. Everything
the team writes that isn't code lives here: skills, glossary,
contributing notes, post-mortems, the project's own claude.md.
Optimistic concurrency (expected_version on every write) means two
agents editing the same doc in parallel get a clean conflict instead
of a silent overwrite. Agents read these via nottario.docs.read
to learn the project's conventions before they touch code; humans
browse and edit them in the web UI.
Architecture — a living map of the codebase. Nodes are services,
modules and components; edges are calls, data flow and dependencies.
Hand-rolled SVG over an ELK-computed layout, with a detail rail that
surfaces description, linked repo path, and incident edges per
selected node. Agents are the cartographers: every time work
adds, removes or rewires a component, the same agent updates the
diagram via nottario.arch.upsert_node/upsert_edge, so the map
never goes stale against the code. Humans browse it read-only as a
living-system overview.
- Docker and Docker Compose.
- A GitHub OAuth App for the instance:
https://github.com/settings/developers → New OAuth App.
- Homepage URL:
http://localhost:8080 - Authorization callback URL:
http://localhost:8080/auth/github/callback - Leave Device Flow disabled and the webhook section empty.
- Generate a client secret and keep it for the
.envfile.
- Homepage URL:
Copy the example env file and fill in the secrets:
cp .env.example .envThen edit .env with:
PUBLIC_URL=http://localhost:8080
HTTP_ADDR=:8080
DATABASE_URL=postgres://nottario:nottario@db:5432/nottario?sslmode=disable
GITHUB_OAUTH_CLIENT_ID=<your client id>
GITHUB_OAUTH_CLIENT_SECRET=<your client secret>
SESSION_KEY=<32 random bytes, base64>Generate SESSION_KEY with:
openssl rand -base64 32Everything runs through Docker Compose:
docker compose up --buildThat builds the nottario image, brings up Postgres, applies all
pending migrations on first boot and exposes the web UI at
http://localhost:8080.
The first GitHub account that logs in becomes the instance admin.
To stop and discard state:
docker compose down -vTo keep state (the db-data volume) between runs:
docker compose downThe same binary that runs locally is what ships to production. Below is the minimum needed to put it behind a real domain with TLS, a real Postgres, and a sensible secret hygiene story.
Nottario does not terminate TLS. A reverse proxy in front of it
handles certificates and forwards plain HTTP to :8080. Set
PUBLIC_URL=https://<your-domain>; the binary detects the https://
prefix and turns on the Secure flag for session cookies
automatically. Anything else (cookies on http, mixed-content) is not
supported.
Minimal Traefik snippet — drop into the compose file that already runs
Traefik on your host. No ports: block on Nottario; the proxy is the
only thing the public reaches.
services:
nottario:
image: ghcr.io/neverbot/nottario:latest
restart: unless-stopped
networks:
- <your-traefik-network>
depends_on:
- postgres
environment:
PUBLIC_URL: https://nottario.example.com
HTTP_ADDR: ":8080"
DATABASE_URL: postgres://nottario@postgres:5432/nottario?sslmode=disable
GITHUB_OAUTH_CLIENT_ID: <your client id>
GITHUB_OAUTH_CLIENT_SECRET_FILE: /run/secrets/nottario_github_secret
SESSION_KEY_FILE: /run/secrets/nottario_session_key
secrets:
- nottario_github_secret
- nottario_session_key
labels:
- traefik.enable=true
- traefik.http.routers.nottario.rule=Host(`nottario.example.com`)
- traefik.http.routers.nottario.entrypoints=websecure
- traefik.http.routers.nottario.tls.certresolver=<your-resolver>
- traefik.http.services.nottario.loadbalancer.server.port=8080For nginx or Caddy: forward https://nottario.example.com → http://127.0.0.1:8080
preserving Host and X-Forwarded-Proto. Nothing else is required.
| Variable | Required | Default | Purpose |
|---|---|---|---|
PUBLIC_URL |
yes | http://localhost:8080 |
The base URL users reach. Must match the OAuth callback host and have https:// in production for Secure cookies. |
DATABASE_URL |
yes | — | pgx connection string. Migrations run automatically on startup. |
GITHUB_OAUTH_CLIENT_ID |
yes | — | From the GitHub OAuth App. |
GITHUB_OAUTH_CLIENT_SECRET |
yes | — | From the GitHub OAuth App. |
SESSION_KEY |
yes | — | 32 random bytes, base64-encoded. Generate once and keep: openssl rand -base64 32. Rotating it logs everyone out. |
GITHUB_OAUTH_ORG |
no | (disabled) | When set to an org slug, OAuth logins are restricted to active members of that GitHub organisation. Non-members land on /login with a flash. The consent screen will additionally request the read:org scope. API tokens are unaffected. |
HTTP_ADDR |
no | :8080 |
Listen address. Change only if you run multiple instances on one host. |
NOTTARIO_BACKUP_DIR |
no | (disabled) | Mount-friendly path where periodic pg_dump backups land. Empty = no backups. |
NOTTARIO_BACKUP_AT |
no | 03:00 |
Time of day for the daily backup, HH:MM 24h local time. |
NOTTARIO_BACKUP_KEEP_DAYS |
no | 7 |
Delete dumps older than this many days after each successful run. |
Every secret variable also accepts a _FILE companion that points at
a file on disk: SESSION_KEY_FILE, GITHUB_OAUTH_CLIENT_SECRET_FILE.
The file's contents are read and trailing whitespace is stripped — so
openssl rand -base64 32 > /run/secrets/session_key works without
extra ceremony. The _FILE variant takes precedence when both are
set; this is the recommended path under Docker secrets, Kubernetes
mounted secrets, or any host where you'd rather not have secrets in
the process environment.
Secret file ownership. The container runs as the distroless
nonrootuser (UID 65532 — fixed by the image, the same on every host; it does not come from the host's user database, and the host need not have a user with that ID), so the_FILEtargets must be readable by that UID. With Docker Composefile:secrets the mounted file keeps the host file's owner and mode, so aroot:root0600secret yieldspermission deniedat startup (read SESSION_KEY_FILE ...: permission denied) and the container crash-loops. Fix it by eitherchown 65532 <secret-file>on the host, or settinguid: "65532"(andmode: 0400) in the composesecretslong syntax. Kubernetes mounted secrets are world-readable by default, so this only bites the plain-Docker path.DATABASE_URLis read from the environment, not a file, so it is unaffected.
- Sign in to GitHub and open Settings → Developer settings → OAuth Apps → New OAuth App.
- Fill in:
- Application name: anything (e.g.
nottario-yourorg). - Homepage URL: your
PUBLIC_URL. - Authorization callback URL:
${PUBLIC_URL}/auth/github/callback. - Leave Device Flow disabled and the webhook section empty.
- Application name: anything (e.g.
- Save, then Generate a new client secret. Copy it once — GitHub only shows it once.
- Put the Client ID and Client Secret into the environment of the running container (see the table above).
Required OAuth scopes: read:user. The MCP server never asks for
elevated GitHub scopes.
The very first user to complete the OAuth login flow on a fresh instance is promoted to instance admin automatically. After that, new logins are regular users until granted admin via project membership.
Adding or revoking instance-admin on subsequent users currently
requires direct SQL (UPDATE users SET is_admin = true WHERE github_login = '…';). A UI for this is on the roadmap; until then,
keep that one human you trust as the first login.
Two supported setups:
-
Bring your own Postgres (production). Point
DATABASE_URLat an external Postgres 16+ instance you already operate. Nottario applies its own migrations on first boot; no manual SQL needed. A dedicated database and role for Nottario is recommended:CREATE ROLE nottario LOGIN PASSWORD '…'; CREATE DATABASE nottario OWNER nottario;
-
Embedded compose Postgres (dev only). The
compose.ymlin this repo ships adbservice with a hardcodednottario:nottariopassword. Convenient for local development; do not run a production instance against it.
Migrations are written as goose files under internal/db/migrations/
and are embedded into the binary. There is no separate migration
command — booting the container is the migration.
Nottario can run periodic backups itself. Set NOTTARIO_BACKUP_DIR
to a host-mounted path and the binary forks a goroutine that runs
pg_dump --format=custom once a day at NOTTARIO_BACKUP_AT (default
03:00 local), naming files nottario-YYYY-MM-DD-HHMM.dump. Files
older than NOTTARIO_BACKUP_KEEP_DAYS (default 7) are pruned after
each successful dump. Backups are disabled when the env is unset.
To enable on a self-hosted deployment:
# 1) Create the host directory and give it to the container's UID.
sudo mkdir -p /var/backups/nottario
sudo chown 65532 /var/backups/nottario# 2) Wire env + volume in your compose service.
services:
nottario:
environment:
NOTTARIO_BACKUP_DIR: /var/backups/nottario
NOTTARIO_BACKUP_AT: "03:00"
NOTTARIO_BACKUP_KEEP_DAYS: "7"
volumes:
- /var/backups/nottario:/var/backups/nottarioThe chown 65532 step is non-negotiable — the container runs as
that fixed nonroot UID and will fail to write the dump otherwise.
The dump files end up owned by UID 65532 on the host too; to copy
them off the box use sudo cp or change ownership after.
Restore. scripts/restore.sh <dump-file> [database_url] runs
pg_restore --clean --if-exists --no-owner --no-privileges. The
script prompts for confirmation before dropping data; pass --yes
to skip it. The default database_url is the dev compose Postgres,
so be explicit when targeting production.
Verify the first run. The simplest smoke test is to point
NOTTARIO_BACKUP_AT at "two minutes from now" before restarting,
wait, then ls -la /var/backups/nottario/ on the host. Reset the
schedule afterwards.
docker compose pull nottario
docker compose up -d nottarioMigrations apply automatically on the new container's boot. A failed migration leaves the previous schema intact; the new container will exit non-zero and the proxy will fall back to whatever it was last serving — but the database state is consistent.
Rolling back is not automatic: goose Down migrations are written defensively but data shape changes (e.g. dropped columns) cannot be reconstructed. Test upgrades on a copy of the production database before applying.
Nottario exposes its full surface area to AI agents through an MCP server bundled inside the same binary. Any MCP-capable client (Claude Code, Claude Desktop, Cursor, etc.) connects over HTTP with a Bearer token.
Tokens in Nottario are scoped to a single project: one token = one project. An agent using a token issued for project A can never read or modify project B, even if the underlying user is a member of both. Agents working across multiple projects need one token per project.
In the web UI:
- Sign in with GitHub.
- Open the project you want the agent to work on.
- Go to Settings → Tokens → New token.
- Give it a name (e.g.
claude-code-laptop) and an optional default role. - Copy the secret — it is shown once and starts with
ntr_…. It is hashed in the database; if you lose it, revoke and issue a new one.
The token authenticates as the user that created it. Admin powers require that the underlying user is an instance admin; admin status is not a bypass of project scope — an admin token issued for project A is still rejected against project B.
Claude Code:
--scope local stores the config in your ~/.claude.json keyed by
working directory. Run claude mcp add from inside the repo that
will use this token — otherwise the entry is associated with the
wrong directory and Claude Code won't see it when you open the repo.
cd /path/to/your/repo
claude mcp add nottario http://localhost:8080/mcp \
--transport http \
--header "Authorization: Bearer ntr_…" \
--scope local--scope local is the recommended setup: each project gets its own
token, so an agent working on project A can't accidentally touch
project B. --scope user puts the same token in every project from
~/.claude.json — only use it if you knowingly want one shared
token. Avoid --scope project (it would commit the token into the
repo).
Verify:
claude mcp get nottarioClaude Desktop: edit claude_desktop_config.json (Settings →
Developer → Edit Config) and add:
{
"mcpServers": {
"nottario": {
"type": "http",
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer ntr_…"
}
}
}
}Restart Claude Desktop after saving.
Cursor: in ~/.cursor/mcp.json (or the project-level .cursor/mcp.json):
{
"mcpServers": {
"nottario": {
"url": "http://localhost:8080/mcp",
"headers": { "Authorization": "Bearer ntr_…" }
}
}
}For a remote deployment, swap http://localhost:8080 for your public
URL. The transport must stay http (Streamable HTTP); plain SSE is
not supported.
For each project tracked in Nottario, repeat the loop:
- Web UI → open that project → Settings → Tokens → New token. Copy the secret.
- In a terminal:
cdto the corresponding local repo and run theclaude mcp add … --scope localfrom step 2 with that token.
A token issued for project A cannot be used against project B; the
MCP server rejects every cross-project call with token scoped to project X, request targets Y. To switch the agent's focus, launch
Claude Code from the repo whose --scope local config carries the
right token — there is no "active project" toggle inside Claude
Code itself.
From the agent, the first call should be nottario.whoami. If it
returns your github_login and your memberships (roles in the
single project the token is scoped to), the connection is
healthy. A 401 Unauthorized with a WWW-Authenticate: Bearer realm="nottario" header means the token is missing, malformed or
revoked. A "token scoped to project X, request targets Y" error on a
later tool call means the agent passed the wrong project_id; cache
the value whoami returns and pass it on every subsequent call.
The same skill files are reachable three ways, in order of preference:
- On demand, via MCP (recommended default). Any session can
call
nottario.skill.listandnottario.skill.readto fetch a file. Always available, always in sync with the server. - Workspace-scoped pre-load (when you want the skill loaded at
session start and you have the tracked repo checked out). Drop
the bundle into
<repo>/.claude/skills/nottario/and commit it. Claude Code loads the skill whenever the workspace is opened, scoped to that repo, so it stays out of unrelated sessions. Every contributor who clones gets the rules for free. - Home-scoped pre-load (fallback). Drop the bundle into
~/.claude/skills/nottario/instead. The skill loads in every Claude Code session regardless of cwd — convenient for multi-repo work or when you don't want to version the bundle, at the cost of surfacing in sessions on repos that have nothing to do with Nottario.
The on-demand path covers correctness; the pre-loaded paths cover ergonomics. For a Claude Code user picking up Nottario for the first time, install it locally so agents do not start each session from zero. From a checkout of the tracked repo:
curl -fsSL http://localhost:8080/skill.zip -o nottario-skill.zip
unzip nottario-skill.zip -d .claude/skills/nottario
git add .claude/skills/nottario && git commit -m "chore: vendor nottario skill bundle"Or, for the home-scoped install, the same unzip into
~/.claude/skills/nottario instead.
The bundle is regenerated on every release; pull it again after an upgrade to get the latest conventions and tool descriptions. Clients that do not support local skills (raw HTTP MCP clients, custom integrations) lean on the on-demand path instead.
401 Unauthorized— token missing, malformed (must start withntr_) or revoked. Issue a new one.404on/mcp— wrong path or the binary is older than the MCP milestone. CheckGET /version.Mcp-Session-Id missingor session errors — your client is not preserving the session header between requests. Update the client or check its transport config; Nottario follows the standard Streamable HTTP transport.- The agent sees no projects — the user behind the token has no
membership in the token's project. An admin must add them via the
web UI (Project settings → Members) or grant
is_admin. token scoped to project X, request targets Y— the agent passed aproject_idthat doesn't match the token's project. Tokens are per-project; either re-issue against the right project or pass the correct id. Cachewhoami'smemberships[0].ProjectIDand use it everywhere.
docker compose up -d # background
docker compose logs -f nottario # follow logs
docker compose restart nottario # restart only the app
docker compose exec db psql -U nottario -d nottario # inspect the databaseAfter editing Go code, rebuild and restart only the app:
docker compose up -d --build nottarioThe test suite is run on the host with the Go toolchain (no Docker required):
go test ./...Backend (Go):
- pgx/v5 — Postgres driver.
- sqlc — type-safe Go generated from SQL.
- goose — embedded schema migrations.
- modelcontextprotocol/go-sdk — MCP server transport.
- goldmark — CommonMark renderer for docs and task descriptions.
- bluemonday — HTML sanitiser that runs after goldmark.
- golang.org/x/oauth2 — GitHub OAuth flow.
- google/uuid — UUID generation and parsing.
- yaml.v3 — YAML frontmatter on skill bundle and shared docs.
- joho/godotenv —
.envloader for local dev. - golang.org/x/tools — analyzer framework for the custom SQL-injection lint.
time/tzdata— embedded IANA zoneinfo soTZ=…resolves on the minimal alpine runtime image.
Frontend (vanilla, no build step):
- Lit — web-components framework for the UI.
- elkjs — vendored layout engine that computes positions for the architecture diagram (we render the SVG ourselves).
- highlight.js — vendored syntax highlighting inside rendered markdown.
Infrastructure:
- Postgres — primary datastore.
pg_dumpandpg_restorefrompostgresql17-clientship inside the runtime image, used by the in-process backup goroutine and byscripts/restore.sh. - Docker / Docker Compose — local and deploy runtime; the runtime image is
alpine:3.21+ the binary.
Tooling:
- gofmt, go vet, golangci-lint — Go lint stack chained by
make check. internal/tools/sqlcheck— in-tree golangci-lint analyzer that flags anyfmt.Sprintfor string concatenation of runtime values feedingpgx'sQuery/Exec/QueryRow.- Biome — JavaScript format + lint gate for
internal/web/static/. Runs vianpx --yes @biomejs/biomeso there is nonode_modulesin the repo; the Rust binary is cached under~/.npm/_npx/after first use.
MIT — see license.md.



