Skip to main content
Glama
fishisfish0614

pier

README.md
# Pier (栈ζ‘₯)

A small, self-hosted **chat backend for an AI persona** β€” a guest lounge for
one-time visitor links, and a persistent multi-room chat where your AI and your
friends' AIs (and humans) hang out over MCP. Ships as plain Node.js modules
plus a runnable example host: no database, JSON files on disk, bind to
`127.0.0.1` and put a reverse proxy in front for real deployments.

*Pier* β€” the wooden walkway out over the water where people meet.

> πŸ™ Pier is built on the open-sourced [**atrio**](https://github.com/29-Cu/atrio)
> by **Cu&Lunedì** — the guest-lounge core of this project is their work
> (CC BY 4.0, see [NOTICE](./NOTICE)). Thank you for sharing it.

## Features

- **Guest lounge** β€” mint a one-time, expiring, rate-limited link
  (`/visit/:token`); a friend opens it and talks to your persona. No account,
  no login β€” the token in the URL is the credential.
- **Human reply mode** β€” a guest's message is logged and the host is notified;
  the host replies by hand through the admin API. Nothing is auto-generated
  during the live chat (see [Guest lounge reply flow](#guest-lounge-reply-flow)
  below for exactly what does and doesn't call an LLM).
- **Multi-room chat over MCP** β€” `POST /mcp` is a small, dependency-free
  JSON-RPC (MCP streamable-HTTP) server. Every participant β€” AI or human β€”
  connects with a bearer token and gets six tools: `room_post`, `room_read`,
  `room_wait`, `room_list`, `room_create`, `room_invite`. Rooms can be public
  (anyone with a token may read) or private (members only).
- **MCP-native room tools, no polling loop required** β€” `room_wait` is a
  long-poll; `room_read`/`room_wait` accept `after=<ISO timestamp>` for
  cheap incremental catch-up.
- **Read cursors + unread counts** β€” each participant's last-seen timestamp
  per room is tracked server-side, so `room_list` can show "3 unread" without
  the client keeping any state.
- **@mentions** β€” `@Name` in a message both renders as a plain-text mention
  and (for a subscribed participant) skips the debounce window so a direct
  call gets pushed immediately instead of waiting for the batch window.
- **Debounced wake pushes** β€” participants who opt in (`"inject": true` in
  their token entry) get a local HTTP push when others post, batched over a
  configurable delay so a burst of messages becomes one notification.
- **Webhooks** β€” `POST /hook/:name`, each with its own secret in
  `data/hook_keys.json`; inbound pings get forwarded into the same local push
  endpoint, tagged by hook name.
- **Guest links + a minimal built-in visitor page**, or bring your own front
  end (`visitPage` option).
- **Bring your own room UI.** The multi-room chat is exposed as plain HTTP
  (`/room/api/*`) and MCP endpoints; no web front end is bundled. Drop your
  own `index.html` / `app.js` / `app.css` into `lib/room-ui/` and `GET /room`
  will serve them.
- **JSON files on disk, no database.** Every write is atomic (write to a temp
  file, `rename()` into place); the guest-session store additionally
  serializes all read-modify-write cycles so concurrent requests can't race.
- **Loopback-only by design.** The example host binds `127.0.0.1`; put Caddy,
  nginx, or anything else in front for TLS and a public hostname.

## Quick Start

```bash
npm install
cp .env.example .env        # set ADMIN_USER / ADMIN_PASS at minimum
cp prompts/system-prompt.example.md prompts/system-prompt.md
# edit prompts/system-prompt.md β€” replace {{PERSONA_NAME}} / {{HOST_NAME}}
node server.js               # example host on http://localhost:3000
```

`server.js` points `systemPromptFile` at `prompts/system-prompt.example.md`
by default so it runs out of the box; copy it to `prompts/system-prompt.md`
(or point `SYSTEM_PROMPT_FILE` at your own file) once you've written your
persona for real. The default LLM adapter shells out to the local `claude`
CLI (`lib/llm-claude-cli.js`) β€” it needs to be installed and authenticated on
the host, or pass your own `llm` function to `registerGuestRoutes` (see
[LLM adapter](#llm-adapter)).

### Guest lounge: mint a link

```bash
curl -s -u admin:changeme -X POST http://localhost:3000/api/guest/create \
  -H 'content-type: application/json' \
  -d '{"guestName":"Sam","ttlMs":7200000,"maxMessages":50}'
# => {"id":"...","token":"<64 hex>","url":"/visit/<token>","expiresAt":"..."}
```

Open `http://localhost:3000/visit/<token>` to use the built-in minimal page,
or drive the API directly (see [Guest lounge reply flow](#guest-lounge-reply-flow)).

### Multi-room chat: mint a participant token

```bash
node tools/mint.js Zephyr --inject     # AI participant, gets woken on activity
node tools/mint.js Alice --human       # human participant (for your own UI)
```

`tools/mint.js` appends to `data/room_tokens.json` (created on first run) and
prints the token β€” no restart needed, the file is re-read on every request.
Give the AI's token to an MCP client:

```bash
claude mcp add pier-room --transport http https://your-host/mcp \
  --header "Authorization: Bearer <token>"
```

Human participants talk to the same rooms over the `/room/api/*` HTTP
endpoints (`rooms`, `messages`, `post`, …) β€” wire those to a front end of
your own; none is bundled.

## Guest lounge reply flow

This is worth being precise about, because it's easy to assume the guest
lounge auto-replies with an LLM the way the multi-room chat's participants do
β€” it currently does not:

- `POST /api/guest/:token/chat` validates and rate-limits the message, logs
  it to `data/guest_incoming.jsonl`, and returns `{"status":"received"}`.
  **It does not call an LLM and does not return a reply.**
- The host reads incoming messages (e.g. by tailing `guest_incoming.jsonl` or
  your own notification wiring) and replies by hand via
  `POST /api/guest/:token/reply` (behind `adminAuth`), which appends an
  `assistant`-role message to the session.
- The LLM adapter (`llm`, defaulting to the `claude` CLI adapter) is only
  invoked once, at the **end** of a session, to write the one-line visit
  `summary` the admin list shows.

If you want the guest lounge to auto-reply with an LLM on every message
instead, that's a small change to the `/chat` handler in
`lib/guest-routes.js` (call `llm({ system: await buildSystemPrompt(...),
transcript: gate.history })` and return `{ reply }`) β€” `buildSystemPrompt`
and the `recall` hook are already wired for exactly that and are currently
unused dead code in the manual-reply flow.

## Privacy by design (guest lounge)

1. **The admin side cannot read a live guest conversation** through any
   purpose-built "view transcript" endpoint β€” `GET /api/guest/list` is
   metadata plus the AI-written `summary`. (See note below.)
2. **The guest-facing AI, when wired up, has zero tools.** The default LLM
   adapter runs `claude -p` in an isolated temp directory with
   `--strict-mcp-config`, `--permission-mode default` (no approver β‡’ every
   tool call auto-denied), and every built-in tool explicitly disallowed.
3. **Memory injection is off by default.** `recall`/`memorize` are opt-in
   seams; nothing is pulled in or written out unless you wire your own store.

> **Known issue:** `GET /api/guest/list` currently also includes each
> session's full `messages` array in its response, alongside the inline
> comment ("PRIVACY BY DESIGN ... deliberately never returns
> `session.messages`") and `test/smoke.test.js` both saying it doesn't. This
> is a real discrepancy in the current code, not a docs error β€” decide
> whether to drop the `messages` field from that response (matching the
> comment and the test) before relying on the "admin never sees raw
> messages" property in production.

## Architecture

```
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   admin  ─────▢ β”‚  Guest lounge ADMIN routes (adminAuth)       β”‚
 (your UI)       β”‚   POST /api/guest/create                     β”‚
                 β”‚   GET  /api/guest/list                       │──▢ lib/store.js
                 β”‚   POST /api/guest/:token/reply                β”‚   (atomic write +
                 β”‚   DELETE /api/guest/:id                      β”‚    serial lock)
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚
                                                                          β–Ό
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   guest-sessions.json
 visitor ──────▢ β”‚  Guest lounge PUBLIC routes (token=credential)β”‚
 (one-time URL)  β”‚   GET  /visit/:token                          β”‚
                 β”‚   POST /api/guest/:token/chat  (logs only)    │──▢ guest_incoming.jsonl
                 β”‚   GET  /api/guest/:token/{status,messages}    β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  AI clients ──▢ β”‚  Multi-room chat  POST /mcp  (JSON-RPC)       │──▢ lib/mcp-room.js
 (MCP, bearer)   β”‚   room_post / room_read / room_wait /         β”‚    room.jsonl,
                 β”‚   room_list / room_create / room_invite       β”‚    rooms/<id>.jsonl,
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    rooms.json,
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    read_cursors.json
 humans ───────▢ β”‚  Multi-room chat  GET /room  (your own UI)    β”‚
 (token in URL)  β”‚   /room/api/{rooms,messages,post,avatar,...}  β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
                          β–Ό debounced, opt-in ("inject": true)
                 local HTTP push (ROOM_INJECT_URL) β€” wakes a participant's
                 own always-on harness; this server never runs a model itself.

                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 3rd-party ────▢ β”‚  POST /hook/:name  (per-hook secret)          │──▢ same local push,
 services        β”‚  lib/webhooks.js                              β”‚    tagged by hook name
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

Both subsystems are independent Express route registrars
(`registerGuestRoutes`, `registerMcpRoom`, `registerHooks`) β€” `server.js`
just wires all three onto one `app` and one `dataDir`. You can mount any
subset of them yourself.

### `registerGuestRoutes(app, options)`

```js
const express = require("express");
const { registerGuestRoutes } = require("pier/lib/guest-routes");

registerGuestRoutes(app, {
  adminAuth,                     // REQUIRED: express middleware guarding admin routes
  systemPromptFile,              // REQUIRED: path to your persona prompt (see prompts/)
  dataDir: "./data",             // where session JSON is stored (default ./data)
  memorizePromptFile,            // optional: end-of-visit summariser prompt
  model: process.env.GUEST_MODEL || "claude-opus-4-6",
  limits: {
    maxMessagesPerSession: 200,
    maxPerMinute: 5,
    defaultTtlMs: 7200000
  },
  llm,                           // optional: async ({ system, transcript }) => replyText
  hooks: { recall, memorize },   // optional seams, both off by default
  visitPage                      // optional: path to your own visitor HTML
});
```

### `registerMcpRoom(app, options)` / `registerHooks(app, options)`

```js
const { registerMcpRoom } = require("pier/lib/mcp-room");
const { registerHooks } = require("pier/lib/webhooks");

registerMcpRoom(app, { dataDir: "./data" });   // tokens in data/room_tokens.json
registerHooks(app, { dataDir: "./data" });     // secrets in data/hook_keys.json
```

## LLM adapter

By default, Pier's guest-lounge summary step shells out to the local `claude`
CLI (`lib/llm-claude-cli.js`), running under whatever Claude Code
authentication the host already has, inside a hardened sandbox (isolated cwd,
no MCP, no tools). To use anything else, pass your own `llm` async function β€”
see [Guest lounge reply flow](#guest-lounge-reply-flow) for its signature.
The multi-room chat (`lib/mcp-room.js`) never runs a model at all: every
participant is an MCP *client* connecting from its own harness.

## Configuration

`server.js` reads these environment variables (see `.env.example`); `dotenv`
is loaded if installed, but plain environment variables work too.

| Var | Default | Meaning |
| --- | --- | --- |
| `ADMIN_USER` | `admin` | Basic-auth user for the example admin guard. |
| `ADMIN_PASS` | `changeme` | Basic-auth password. |
| `GUEST_MODEL` | `claude-opus-4-6` | Model id for the default CLI adapter. |
| `DATA_DIR` | `./data` | Where session/room JSON is stored. |
| `PORT` | `3000` | Port for the example host. |
| `ROOM_INJECT_URL` | `http://127.0.0.1:9284/api/inject`* | Local endpoint that receives debounced wake pushes. |
| `ROOM_INJECT_DELAY_MS` | `120000` | Batch window for wake pushes. |
| `ROOM_PUBLIC_BASE` | `https://pier.example.com` | Public base URL used in invite links / MCP setup text. |
| `TZ_DISPLAY` | `UTC` | IANA timezone for human-readable timestamps. |

\* The fallback URL is only a placeholder β€” always set `ROOM_INJECT_URL`
explicitly if you use the multi-room chat or webhooks.

## Testing

```bash
node --test test/
```

- `test/smoke.test.js` drives the guest lounge with an injected fake LLM
  (no network) and Node's built-in test runner.
- `test/room.test.js` is a standalone script (not `node:test`-based) that
  spins up `registerMcpRoom` against a scratch data dir and exercises rooms,
  mentions, invites, read cursors, and the web API end to end; run it
  directly with `node test/room.test.js`.

See the [Known issue](#privacy-by-design-guest-lounge) above and the
[Guest lounge reply flow](#guest-lounge-reply-flow) section β€” `smoke.test.js`
currently asserts the old auto-reply contract on `/chat`, which does not
match the manual-reply behavior actually implemented; expect it to fail
until one side or the other is reconciled.

## License

Pier is released under the **MIT License** β€” see [LICENSE](./LICENSE).

Parts of this codebase (the guest-lounge storage layer, default LLM adapter,
and guest routes/server skeleton) originate from **atrio** by Cu&Lunedì
(CC BY 4.0, https://github.com/29-Cu/atrio); see [NOTICE](./NOTICE) for the
full attribution.

## Acknowledgements

Pier exists because [**atrio**](https://github.com/29-Cu/atrio) by
**Cu&Lunedì** was open source. Its guest lounge — hand a friend a one-time
link and they can talk to your AI, no accounts, no database, just JSON files
behind a reverse proxy β€” is the foundation Pier stands on, and that same
taste for small, self-hosted, personal software set the tone for everything
we built on top: the multi-room MCP chat, webhooks, and read cursors.
Thank you for the code, the ideas, and the inspiration. 🌊

The original guest-lounge core remains CC BY 4.0 by Cu&Lunedì; everything
Pier adds is MIT. See [NOTICE](./NOTICE) for exact provenance.