LOG // Atlas Architecture: A High-Level Overview
The Current State of Atlas was a progress report: what works, what does not, and what I learned along the way. This post is the map. It walks through the major pieces of Atlas and Tapper Hub and how a request moves between them, without trying to cover every feature.
The Short Version
Atlas is the web interface of Tapper Hub, which ships as a single Go binary. The browser, the REST API, and the MCP endpoints all enter through one router, call the same application operations, pass through one authority check, and land in PostgreSQL. A separate outbound connection, the relay, lets machines I own lend their models and tools to Hub without opening a port.
flowchart LR
browser["Browser"]
cli["tap CLI"]
agent["AI agent"]
relay["tap relay<br/>(my machines)"]
subgraph hub["Tapper Hub (one Go binary)"]
direction LR
subgraph surfaces["Delivery surfaces"]
direction TB
web["Atlas<br/>Go templates + HTMX"]
rest["REST API"]
mcp["MCP"]
chat["Chat runs"]
end
ops["Shared operations<br/>KEG · catalog · account"]
gate["Authority gate"]
repo["KEG repository"]
web --> ops
rest --> ops
mcp --> ops
chat --> ops
ops --> gate --> repo
end
browser --> web
browser --> chat
cli --> rest
agent --> mcp
relay -. "outbound connection" .-> hub
repo --> db[("PostgreSQL<br/>pgvector · pg_trgm · AGE")]
Every surface is thin. The business rules live in the shared operations, and every KEG access passes the same gate before it reaches storage.
Delivery Surfaces
Atlas renders pages with Go’s html/template and layers HTMX on top. There is no JavaScript build step. Every route returns complete HTML, and HTMX swaps only the regions that change. Live updates come from PostgreSQL LISTEN/NOTIFY: node and graph pages receive them over WebSockets, and chat streams over server-sent events.
The REST API is what the tap CLI talks to. Tapper has two implementations of the same KEG interface: one backed by a local directory and one backed by Hub over HTTP. The CLI commands do not care which one they get.
MCP is how agents work with KEGs. Hub serves several MCP endpoints with different jobs: the main one for agent sessions, one that supplies the chat assistant’s tools, and one that exposes tools forwarded from relays.
None of these surfaces implement KEG behavior themselves. Each one translates its own protocol into a call to the shared operations underneath.
Shared Operations and One Authority Gate
The application layer has three APIs: KEG operations, catalog operations (namespaces, KEGs, grants, flights, agents), and account operations. The KEG engine is Tapper itself, imported as a Go library. The same code that edits a node in a local KEG on my laptop edits a node in a hosted KEG. Only the repository underneath differs.
Access to a KEG goes through a single gate that opens it for a specific role. The effective role is the highest of three sources: namespace membership, an explicit grant on that KEG, or viewer access if the KEG is public. Roles are viewer, editor, and admin. Agents and flights can narrow that role but never raise it.
Today that boundary is enforced by convention and review rather than by the compiler, because handlers still hold concrete services. Tightening that is on my list. So far, the rule “one gate, no bypasses” has been the most valuable constraint in the codebase.
PostgreSQL for Everything
Hub’s persistence layer is PostgreSQL only. It requires PostgreSQL 18 with the pgvector, pg_trgm, and Apache AGE extensions, and it checks for them at startup. The data is split into a few schemas: authentication, accounts and namespaces, and the KEG catalog itself.
Node content, metadata, attachments, images, generated indexes, and snapshots are all stored in the database. There is no separate object store. That costs some storage efficiency, but one backup captures the whole system and nothing can drift out of sync between a database and a bucket. At my scale, that is the right trade.
Search is BM25F computed in SQL over postings the database maintains itself. An optional semantic layer adds one pgvector embedding per node, built from its title, metadata, and body, and fuses those results with the keyword ranking. Explicit structure such as links, tags, and attributes is still the primary way to find things. Semantic search is an extra way in, not a replacement.
Identity, Agents, and Flights
Hub is its own OAuth2 server, including the device-authorization flow that tap auth login uses. Namespaces are personal or organizational, and KEGs within them are public or private.
The model for agents rests on one sentence from my design notes: the agent is the unit of authority; a flight is memory.
- An agent is a model, its instructions, a tool allowlist, and a flight. Hub stores the agent record. Today the work runs in an agent host such as Claude Code, or in Hub’s own chat.
- A flight decides which KEGs are in scope (following cross-KEG links to a bounded depth) and what the session should be doing. It carries no tools of its own.
- Orientation is the moment an agent session adopts its flight’s context. An agent without a flight lands in a recovery mode where its KEG tools are locked.
Tool policy sits on top: each server, tool group, or individual tool can be allowed, auto-approved, set to require approval, or blocked. A token bound to an agent cannot manage credentials, change membership, or approve logins, even if the human behind it could.
Chat and the Relay
Atlas includes a chat assistant that works with the same KEGs and tools as everything else.
When I send a message, Hub starts a run. The reply loop executes on the server and streams back over server-sent events, so closing the tab does not kill it. Each model call goes through a translation layer that speaks the OpenAI, Anthropic, and Responses formats. Tool calls run in process against the chat’s MCP tools. When a tool needs approval, the run is parked in the database until I answer.
Models can come from two places. Hub has managed models of its own through OpenRouter. The more interesting source is the relay.
tap relay runs on a machine I own and dials out to Hub, so it needs no inbound port. It can offer that machine’s model providers (Ollama, OpenRouter, any OpenAI-compatible endpoint) and any MCP servers I configure. Provider keys and MCP settings never leave the machine. Hub only sees the models and tools the relay chooses to list, and each relayed server has to be enabled and shared explicitly.
The relay can also expose runners: coding-agent CLIs on that machine wrapped as tools. I keep Claude Code logged in with my Claude subscription on a dedicated runner. The relay forwards Claude Code’s own MCP tools along with a claude_code_run tool that hands a whole task to a real Claude Code session in an allowed directory. As a result, the chat assistant in Atlas can call Claude Code: I can ask for a change in the browser and the work runs on my hardware under Claude Code’s own permission settings. The runner is shared only with my account.
Today that runner is my Mac. I am moving it to a small dedicated VM in my homelab so it stays online without my laptop, but the architecture does not change: whichever machine runs the relay is the runner.
sequenceDiagram
participant Me as Me (Atlas chat)
participant Hub as Tapper Hub
participant Relay as tap relay (runner)
participant CC as Claude Code
Relay->>Hub: dial out, list models and tools
Me->>Hub: send message
Hub->>Hub: start run, call model
Hub-->>Me: stream reply (SSE)
Hub->>Relay: tool call: claude_code_run
Relay->>CC: run task in allowed directory
CC-->>Relay: result
Relay-->>Hub: tool result
Hub-->>Me: continue streaming
The relay’s connection always starts on my side. Hub forwards tool calls over that connection; it never reaches into my network.
The relay is experimental. Hub keeps the registry of connected relays in memory, so it currently assumes a single Hub instance.
Deployment
Atlas runs in my homelab on a single host that I configure with Ansible.
- Two Hub instances: the public one at atlas.foldwise.ai and a private one for my own KEGs that is reachable only from inside my network. Each runs as a rootless Podman container under its own service user, bound to loopback.
- Images are built on the host from a release tag. There is no registry in the path.
- The public instance has no open inbound ports. Traffic arrives through a Cloudflare Tunnel, which hands it to Traefik on the host, which routes it to Hub.
- PostgreSQL runs on the host, with a database and a role for each instance.
- Backups: a nightly dump goes to a NAS with 30 days of retention, and every deploy that changes the image takes a verified recovery point first.
- Observability: Prometheus scrapes each Hub’s metrics endpoint, and Loki and Grafana collect logs and draw the dashboards.
flowchart LR
internet["Internet"] --> cf["Cloudflare Tunnel"]
cf --> traefik["Traefik"]
lan["Home network"] --> traefik
traefik --> atlas["atlas-hub<br/>(public)"]
traefik --> private["private hub<br/>(LAN only)"]
atlas --> pg[("PostgreSQL 18")]
private --> pg
pg --> nas[("Nightly backups<br/>to NAS")]
Shipped, Experimental, and Next
- Shipped: the contextual workspace, KEG and catalog operations over web, REST, and MCP, flights and orientation, agents with tool policy, schemas and validation, snapshots, trash, archive export and restore, the public explore directory, and search.
- Experimental: the relay, runners, and
tap launch. A second-generation KEG format backed by an Apache AGE graph is in preview behind a flag. - Next: invitations, an inbox, background archive restores, and stronger token storage are built and waiting on a release. Further out are a Hub-hosted loop that runs namespace agents directly and delegation between agents.
The Seams That Matter
If I had to reduce the architecture to three rules:
- Surfaces translate; operations decide. A browser route and an MCP tool can look completely different, but both must agree on what a KEG operation means.
- One gate for authority. Identity sets the outer boundary, and agents and flights can only narrow it.
- Connections to my machines start on my side. The relay lets the hosted service use my hardware, my models, and my Claude Code session without my network ever accepting an inbound connection.
Those seams are what let me redesign Atlas’s interface, add a chat assistant, or move a runner from a laptop to a VM without destabilizing the parts that already work.