Skip to main content
Glama
fishisfish0614

pier

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 by Cu&LunedΓ¬ β€” the guest-lounge core of this project is their work (CC BY 4.0, see 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 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.

Related MCP server: huddora-omp

Quick Start

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).

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).

Multi-room chat: mint a participant token

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:

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)

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)

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 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

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 above and the 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.

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 for the full attribution.

Acknowledgements

Pier exists because 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 for exact provenance.

Related MCP Connectors

Related MCP Servers