A chat app where every conversation is a tree. Each turn is a node; you can fork from any node and explore a different branch. Each path from the root is an independent context sent to the LLM. Turns can also invoke backend-hosted tools (read, write, bash) and loop for multiple rounds inside a single node, with a per-call approval UI for anything potentially destructive.
| Layer | Tech |
|---|---|
| Frontend | React 19 · Vite · TanStack Router · TanStack Query · shadcn/ui · zustand · xyflow (tree view) |
| Backend | Rust · Axum · sqlx · PostgreSQL · async-openai (OpenAI Responses API) + custom Anthropic client |
| Tooling | pnpm · biome · bacon · sqlx-cli · just · cargo-nextest |
Two LLM protocols are supported: OpenAI Responses API and Anthropic Messages API. A single session is pinned to one protocol at creation time. Tool-calling is wired through both protocols (OpenAI function calling / Anthropic tool_use). For design details, see specs/; for architectural deep-dives, see docs/case-studies/.
fork-chat/
├── fork-chat-backend/ # Axum + Postgres service
│ ├── migrations/ # sqlx migrations (single init migration in early dev)
│ └── src/ # handlers, db, llm adapters (openai + anthropic), tooling, turn lifecycle
├── fork-chat-frontend/ # Vite + React app
│ └── src/ # pages, routes, components, store, api, hooks (SSE turn stream)
├── specs/ # design notes (init.md, multi-protocol.md, tool-use.md)
├── docs/case-studies/ # architectural deep-dives
├── AGENTS.md # agent guidance
└── CLAUDE.md # agent guidance
- Node.js ≥ 20 and
pnpm - Rust (stable) and
cargo justfor the repo-level local workflow- PostgreSQL 14+ for local development, or Docker for
just db-up sqlx-cli(cargo install sqlx-cli --no-default-features --features postgres)- Docker for backend integration tests (
testcontainers) - Optional:
bacon,cargo-nextest
If you want the app to run like a finished product locally, the repo now uses a root justfile to separate "build once" from "restart quickly".
pnpm --dir fork-chat-frontend install
cp fork-chat-backend/config.example.json fork-chat-backend/config.json
cp fork-chat-backend/.env.example fork-chat-backend/.env
# fill in the provider API keys/models you want to use
just build
just run
# app: http://127.0.0.1:<backend port from config.json, default 3000>Notes:
just buildcompiles the frontend bundle and the backend release binary.just runstarts local Postgres and launches the already-built backend binary, so restart time stays low.just upis the convenience "build then run" path for the first launch.- Stop the backend with
Ctrl-C, then runjust db-downif you also want to stop the local Postgres container. - The backend now runs SQL migrations automatically on startup, so a fresh
local deploy does not need a separate
sqlx migrate run. - When
fork-chat-frontend/dist/index.htmlexists, Axum serves the built SPA directly and usesindex.htmlas the fallback for client-side routes. - If startup fails with
migration ... was previously applied but has been modified, runjust db-nukeand thenjust run.
For split frontend/backend development from the repo root:
just devThat starts:
- backend API on
http://127.0.0.1:<backend port from config.json, default 3000> - frontend Vite dev server on
http://127.0.0.1:5173
If you want them in separate terminals, use just dev-backend and
just dev-frontend.
If the backend port changes, you do not need to edit the Vite config anymore.
Vite reads fork-chat-backend/config.json by default and keeps the /api
proxy in sync with server_addr.
# fork-chat-backend/config.json
{
"server_addr": "0.0.0.0:4000"
}cd fork-chat-backend
cp .env.example .env # usually only DATABASE_URL is needed here
cp config.example.json config.json
# then edit config.json to fill in provider api keys, models, etc.
just db-up # optional: starts local Postgres via Docker
just reset-db # drops, recreates DB and runs migrations
cargo run # starts server on $server_addr (default 0.0.0.0:3000)Configuration is driven by a JSON file (see config.example.json). Providers are declared explicitly and each one advertises which protocols (openai, anthropic) it speaks and under which base URL/API key. The frontend reads the resulting provider/model/protocol matrix from GET /api/config.
If you already built the frontend, the backend will automatically try to serve
../fork-chat-frontend/dist relative to the backend crate. You can override
that path explicitly with FORK_CHAT_FRONTEND_DIST_DIR.
Environment variables (see .env.example):
| Variable | Purpose |
|---|---|
FORK_CHAT_CONFIG |
Path to the JSON config file. Defaults to ./config.json. |
FORK_CHAT_FRONTEND_DIST_DIR |
Optional path to the built frontend dist directory for Axum static serving. |
DATABASE_URL |
Postgres connection string. Overrides database_url from the JSON file if set. |
FORK_CHAT_<KEY> |
Any JSON field can be overridden via env (use __ as the nesting separator). |
String values in config.json can also reference environment variables with
${NAME} placeholders. This is the recommended way to keep provider API keys
out of the JSON file:
{
"api_key": "${DEEPSEEK_API_KEY}"
}cd fork-chat-frontend
pnpm install
pnpm dev # http://localhost:5173Vite now proxies /api/* to the backend port from
fork-chat-backend/config.json, so local frontend development still works
without hardcoding a separate API origin in the browser bundle.
Other scripts: pnpm build, pnpm typecheck, pnpm lint, pnpm format, pnpm check (biome lint + format), pnpm check:fix.
Turns are not a single request/response: the backend runs a multi-round loop where the model can request tool calls, the backend executes them, feeds the results back, and lets the model continue reasoning — all inside one tree node. Three tools ship in v1 (see fork-chat-backend/src/tooling.rs):
| Tool | Inputs | Default policy |
|---|---|---|
read |
path |
auto |
write |
path, content |
require_approval |
bash |
command, cwd?, timeout_sec? |
require_approval |
Permission resolution is three-layered per call:
- Unknown tool → synthetic
is_error: trueresult (error.kind = "unknown_tool"), loop continues. - Session
tool_allow_rules— bare tool name (write) orbash(pattern)with*wildcards (e.g.bash(cargo check *)). - Default tool policy —
autoruns immediately,require_approvalsuspends the turn.
When approval is needed the turn transitions to awaiting_approval, pending calls are persisted in runtime_state, and an approval_needed SSE event is emitted. The frontend renders one prompt per pending call with Allow / Always allow this tool / Deny; "always" derives a rule and appends it to sessions.preferences.tool_allow_rules. Denied calls produce a synthetic error result so the model can recover within the same turn. POST /cancel signals the background task via CancellationToken and drops any in-flight bash child (kill_on_drop). Tool output is truncated to 20,000 characters.
The SSE stream emits monotonically sequenced events: turn_started, round_started, turn_snapshot, assistant_entry_appended, tool_calls, approval_needed, tool_result_appended, turn_completed, turn_failed. A fresh turn_snapshot is sent on every subscribe so reconnects catch up without replay.
- Backend tests:
cargo testruns the full backend suite. Integration tests usetestcontainersto start isolated PostgreSQL containers, so Docker must be running.just testruns the same suite throughcargo nextest run. - Frontend tests: run
pnpm test:installonce to install Chromium for Vitest browser mode, then usepnpm test:run(aliaspnpm test). Usepnpm test:nodeorpnpm test:browserto run one project,pnpm test:watchduring development, orpnpm test:uifor the Vitest UI. - Lint: frontend uses Biome (
pnpm check:fix); backend usescargo fmt/cargo clippy. - Pre-commit gate: see AGENTS.md for the required lint/typecheck/test sequence on both sides.
Not yet specified.
