People's Hoard MCP Server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@People's Hoard MCP ServerLog that I talked to Maria today and set a reminder to follow up next week."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
People's Hoard
Local-first personal CRM: who people are, what you know about them, when you last talked and what to remember — stored in a single SQLite file on your own computer and exposed to an assistant through MCP.
Spanish version: README.es.md.
Run
Requires Node.js 22.13 or later (it uses the built-in node:sqlite with FTS5). No native modules, no Docker.
npm install
npm run build
npm start # http://127.0.0.1:5182npm run open starts the server and opens the browser on Windows. npm run dev runs the API (node --watch) and Vite together with the /api proxy configured automatically.
The server binds to 127.0.0.1 only. If port 5182 is busy it walks up to the next free port and prints the address; set PORT_STRICT=1 to fail instead.
Environment variables
Variable | Purpose |
| Preferred port (default |
| Do not fall back to another port. |
| Data folder (default |
| Extra host names accepted behind a tunnel (see below). |
| Turn off the 30-minute pass that reads the hub's mail for the last contact (default on; see From the rest of the family). |
| Turn off the background sweep that reads meeting minutes from the Hoard Link hub (default on; see Commitments). |
| Where the Hoard Link hub is (found by itself by default), and a switch to stop sending events. |
| MCP bridge: base URL of the running app (default |
| MCP bridge: where to read the bearer token (default |
Access from your phone (behind a tunnel)
The server binds 127.0.0.1 and only answers requests whose Host is localhost, 127.0.0.1 or [::1]. To reach it from your phone through a tunnel that fronts the app (a private mesh network, a reverse proxy), list the extra host names in PEOPLE_ALLOWED_HOSTS, comma-separated, exact names or *.suffix: PEOPLE_ALLOWED_HOSTS=my-pc.example,*.ts.net. Port and letter case are ignored, and the Origin of API calls must resolve to one of those hosts too (any scheme or port). Cross-site fetches are still refused; opening the app from another page (a link, a bookmarklet, the share sheet) is a normal navigation and works.
Once opened through the tunnel, the browser offers to install it (PWA).
Related MCP server: MCP Google Contacts Server
What it does
Personas — instant search (accent-insensitive, matches partial names anywhere in a word, plus nickname and alias), circle chips as a filter, cards with name, circles, "last contact 12 days ago" and a birthday-soon badge, and a quick new-person form.
Persona page — a live conversation brief combining facts, five recent contacts and open reminders; header (name, nickname, circles, birthday, location, desired contact cadence) editable as one form; summary and notes as textareas that save on blur; facts as an editable key/value list ("le gusta" / "el senderismo"); a contact timeline with a one-line add form ("he hablado hoy"); reminders with due date and a "done" checkbox; an alias editor (WhatsApp, e-mail, phone, other handle); archive, merge-with-a-duplicate and delete.
Commitments — who promised what to whom and by when, in two directions: what you owe a person (
i_owe) and what a person owes you (owed_to_me). A page with filters (direction, status, person, overdue, text), a review queue, a form to add one, and the two ways to bring them in (below); a Compromisos section on every person page; a badge on the navigation with overdue items and proposals waiting. See Commitments below.Agenda — upcoming birthdays with age when the year is known, reminders due, a Compromisos block (overdue and due this week) and an "abandonados" list of people you have not contacted within their desired cadence, with a one-click "he hablado hoy" that logs a quick interaction. Descargar calendario exports active birthdays as yearly events, open reminders and open commitments with a due day as dated events in an
.icsfile.Ajustes — data folder and version, JSON export/import for backups.
Commitments
A commitment has a direction (i_owe / owed_to_me), a person (or the name as written when they are not in the book), a text, a due day (due) or the words used (due_text), a status (open, done, dropped) and its source (funes, chat, manual, text, mail, whatsapp, with a reference, the literal quote and, for chat messages, the time of the message). People's Hoard owns them; other apps only feed it.
From meetings. Funes writes the minutes of a recorded meeting (summary, decisions and action items with evidence). People's Hoard asks for them through the Hoard Link hub (
scribe_minuteson Funes, never by reading Funes's files) and turns each action item into a commitment: what you said you would do isi_owe(the other person is the counterpart), what someone else said isowed_to_me. Names resolve like everywhere else (exact, alias, fuzzy). Nothing goes straight into the list unless the direction and the person are both certain: a name that matches nobody or several people, an owner the minutes do not name (or give asotrosor a speaker label such asS1), my own promise with no recipient the book knows, and a promise between two other people all go to a review queue instead. There you choose who owes (a promise made by me or to me), pick a candidate, create the person or discard; an item already in the list can be corrected the same way with Editar (direction, person, day). The meeting is logged once on each person's timeline as Reunión: . While the hub answers, a sweep every 60 seconds readsfunes.minutes.readyevents (GET <hub>/api/events?type=funes.minutes.ready&since_id=…, with this app's token; the last id is remembered in the database; the first contact starts from "now", so old meetings are not replayed on their own: usecommitments_ingest_minutesfor those).PEOPLE_COMMITMENTS_AUTO=0switches the sweep off. To read a meeting again after a fix,commitments_ingest_minutes(session_id, replace=true)(addregenerate=trueto have Funes write the minutes again) first drops what the earlier reading left that you have not touched (open, never edited, not chosen in the review queue; plus proposals still waiting) and then ingests; done, dropped, edited or deliberately discarded items stay.From text. Paste a mail or a conversation: the local model (through the hub) proposes commitments, each with the exact words that support it; proposals whose quote is not literally in the text are dropped, and everything goes to the review queue, never straight in. With no model loaded the answer is
no_model.From WhatsApp.
commitments_from_messages(tool) /POST /api/commitments/from-messagestakes a batch of messages the caller already read (Faustus hands them over through the hub, only for chats the user enabled; this app never reads WhatsApp) — each{ id, chat_id, contact_name, phone, sender: "me" | "them" (or from_me), text, timestamp (ISO or epoch), is_group? }— and records the promises in them. The pass is deterministic and fixed: Spanish and English promise verbs (te lo mando,te paso,te pago,voy a mandarte,me encargo de,I'll send, …), debts (te debo,me debes,you owe me,tienes que mandarme,quedamos en que me lo pasas) and time expressions (el lunes,mañana,esta semana= that Sunday,la semana que viene,antes del 15,el 15 de octubre,en dos semanas,a fin de mes) resolved against the day the message was written, never against today. Direction follows who is bound:te lo mandofrom you isi_owe, from themowed_to_me;me debesfrom you isowed_to_me, from themi_owe. Each commitment keepssource = whatsapp, the sentence that makes it (source.quote), the message time (source.at) and a reference<chat>|<message id>. Left alone on purpose: questions, conditionals and doubts anywhere in the clause, before or after the verb (si quieres te lo mando,te lo mando el lunes si puedo,unless,a menos que,salvo que,aunque,creo que,no sé si,if possible), hedges (igual,maybe), negations, past tense (te lo mandé), courtesy (te mando un abrazo), promises relayed through the chat (dile a Ana que te lo mando), the figureme debes una, quoted, forwarded and reported text, and group chats. An emphatic yes is not a condition (si o si,sí o sí,no matter what), though a real «si puedo» next to it still cancels the promise. A weekday afterde/delor beforepasadois a reference, not a deadline (la cena del sábado pasado). A message with several promises gives one commitment per clause (te mando el informe el lunes y te llamo el martes); a courtesy clause only drops itself. Irony (sí, claro,eso dijo, laughter plussin falta), a «mañana» written between 00:00 and 05:59 (it may mean the same night) and a phone number in nobody's card whose name fits one person do not enter the list: they wait in the review queue (doubtful,late_night,phone_unknown; settling the last one saves the number on that person's card). A message may carryname_unverified: true(Faustus sets it for a hidden-id chat that only has a nickname and no phone): it is treated like an unknown phone, so it waits in review instead of being matched by the name alone. Emoji and laughter are removed from the action. Weak verbs (te llamo,I'll call you) count only with a day. The person is matched by phone (the alias of any kind, or the number in the chat id), then by name; a name that matches nobody keeps the name as written (no person is created), one that matches several goes to the review queue. A ledger of message ids (a hash of chat and id, never the text) makes re-sending a batch a no-op;reprocess: truereads them again (the same promise is still never recorded twice) and a commitment you deleted does not come back.refine: trueadditionally lets the local model read the messages the rules found nothing in; its findings only go to the review queue, with the quote checked against the message (off by default, never required); the text of those messages then goes to the hub's local model (it stays on the PC), and when the hub or the model is not there they are not marked as read, so the next call tries them again. The events are the usual ones: a promise of yours with a day sendspeople.commitment.added(direction andsource: whatsappin the data, so the hub's deadline rule works unchanged), the restpeople.commitment.noted; the events carry only the action in plain words (Mandar algo,Saldar una deuda,Llamar, orCompromiso de WhatsAppwhen it is not known), never the sentence of the message nor the quote.From the assistant.
commitment_addrecords one said in a chat. A day is only kept if it is a real day or the words name exactly one ("el martes", "en dos semanas", "15 de octubre"); otherwise the words stay asdue_text: nothing is guessed.Events.
people.commitment.added {title, due, ref, person, …}is sent only for a commitment you owe (i_owe) that has a day (when a commitment becomes that later, a day added or the direction fixed, it is sent then): the hub's rule turns it into a deadline in the paperwork app, withref = hoard://people/commitment/<id>pointing back. Any other new commitment (someone owes you, or there is no day yet) is announced aspeople.commitment.notedwith the same data as before.people.commitment.doneandpeople.commitment.overdue(once per item; a new deadline earns a new one) are sent as always.Merge and delete. Merging two people moves their commitments; deleting a person keeps their commitments with the name as written.
From the rest of the family (through the hub)
Everything here talks to the Hoard Link hub and stays quiet without it; nothing is read from other apps' files.
Who was at a meeting.
people_from_minutes {minutes_id}asks Funes for the minutes (minutes_get, falling back toscribe_minuteson an older Funes when asked to write them), matches each attendee by name, alias or e-mail address (several matches are never guessed) and writes Reunión: on the day of the meeting on each person's timeline. It creates nobody: attendees that match no one come back asunmatched, several people asambiguous. It is idempotent per meeting and person, also against the commitments road above (same title, same day, once), and links the person to the minutes in the hub's reference graph (hoard://people/person/<id>tohoard://funes/minutes/<id>). The hub's rule forfunes.minutes.readycalls it by itself.Last contact from mail. The hub reads the inbox once for the family. People's Hoard registers a mail interest whose
from_addressesare the e-mail aliases of the people in the book (not archived people, one per mailbox, at most 300; registered again when the set changes, and a changed set re-reads the hub's stored mail), reads what came from them every 30 minutes while it runs (and withcontacts_sync_mailor Leer ahora in the settings), and, for each person, keeps the date, the channel Correo and the subject as a one-line note, at most one line per person and day. The mail goes through the family'sfamMailRouter(hub only: People has no mail helper), asking for the list and category headers, so a newsletter or a Gmail promotions/social/forums mail from a contact's address is not counted as contact; the body is never stored, and no mail is claimed (it is not this app's). Only while the hub's mail gateway is on (mailAvailable()); the setting Actualizar el último contacto con el correo andPEOPLE_MAIL_AUTO=0turn it off.Gift ideas. Per person: an idea, an optional page and an optional budget (
gift_idea_add,gift_ideas, the Ideas de regalo section of the person's page).gift_watch {idea_id}(or Vigilar precio) asks Tantalus, through the hub, to watch it (watcher_addwith the idea's text, page and budget,source_ref = hoard://people/gift/<id>) and keeps the watcher id; asking again answers the same watcher.gift_days_beforedays before a birthday (setting, default 21) the saved ideas that are still to give go to the daily digest as adigest.itemevent: "Cumpleaños de X en N días: ideas guardadas: …", once per person and birthday.Family agenda.
GET /api/family/agenda(the app's own token) answers with birthdays (birthday, with the age when the year is known), follow-ups due by each person's cadence, last contact plus the days they wanted to talk (followup; one already overdue stays on the first day of a window that includes today) and open commitments with a day (deadline, both directions; what I owe ishigh). The manifest says"x-family": {"agenda": true}.
Birthdays are stored as YYYY-MM-DD (year known) or --MM-DD (year unknown); the upcoming window and age computation handle the Dec→Jan boundary and Feb 29 in non-leap years.
API
All routes are JSON, validated with zod, and answer errors as { "error": "…" } with a proper status code.
Route | Purpose |
|
|
| UI bootstrap: version, data dir, circle counts, commitment counts (open, overdue, proposals waiting). |
| Search/filter people ( |
| People CRUD; |
| Live conversation brief with source ids, differing facts flagged and open commitments both ways. |
|
|
| Nested CRUD for each person. |
| Find a person by name/alias: exact → alias → fuzzy, with candidates and scores. |
| Birthdays in the window (with age when known), reminders due, and "neglected" people. |
| Download all active birthdays, open reminders and open commitments with a due day as iCalendar events; Feb 29 birthdays recur on leap years. |
| Commitments. List filters: |
| Overdue and upcoming, grouped by person, with ready-to-say lines. |
| The review queue; |
|
|
|
|
|
|
| State of the background sweep; |
| Circle names with counts. |
| JSON backup and restore (people, aliases, facts, interactions, reminders and commitments). |
| Gift ideas: |
|
|
| State of the mail pass; |
|
|
| The family agenda contract (birthdays, follow-ups, commitments with a day); needs this app's own bearer token. |
| Tool catalogue (name, description, JSON schema, annotations) and the assistant instructions. |
|
|
| Faustus copilot: |
| The Faustus panel script, relayed from Faustus. |
| The floating button and panel launcher the app adds to every HTML page. |
|
|
Faustus copilot
Every page has a floating Faustus button (bottom right) that opens the Faustus assistant in a side panel. The conversation, model, memory and tools live in Faustus: this app only relays the panel (/api/copilot/*) and keeps no history. It needs Faustus running (HOARD_FAUSTUS_URL, default http://127.0.0.1:7000); without it the button explains that the copilot is unavailable and the rest of the app is unaffected.
What every turn tells the assistant (window.hoardCopilot.context(), read through the app's own API):
view.page(people,person,agenda,commitments,settings), the search and circle filters of Personas, the window of days in Agenda, the commitment filters and the number of proposals waiting in Compromisos.active_objects: on a person's page, the person (circles, birthday, location, summary, counts), their facts, open reminders, open commitments and gift ideas; in Compromisos, the person used as a filter and the commitment whose edit form is open. A creation form,#/personas/newor an unknown id adds no object and never fails.view.editor: the fields typed in the open form and which of them changed (the person's data, the summary and notes drafts, a new fact, alias, contact, reminder, gift idea, commitment or person, the merge search, the pasted meeting or text, the settings). Several open forms are listed inview.editors.
When the assistant finishes a turn the open page reloads its data in place: the list, the person, their commitments and gifts, the agenda. Forms keep what you typed. Editing a person's data saves only the fields you changed, so a change the assistant made to another field in the meantime is not overwritten (the backend PATCH merges).
Turn it off with HOARD_COPILOT=0 (no /api/copilot/* routes, no button). The client is in client/src/copilot-context.js (context and refresh) and client/src/copilot-hooks.js (what each page and form reports).
Connect an assistant (MCP)
server/mcp.js is a stdio MCP server that proxies every call to the running app, so only one process ever opens the database. Keep the app running while the assistant works.
{
"command": "node",
"args": ["C:/path/to/peoples-hoard/server/mcp.js"],
"env": { "PEOPLE_URL": "http://127.0.0.1:5182", "PEOPLE_TOKEN_FILE": "C:/path/to/peoples-hoard/data/mcp-token" }
}faustus-plugin.json describes the app for Faustus (health check, launch hint and the MCP command with placeholders).
Tools (27):
Tool | Purpose |
| Fuzzy, accent-insensitive, partial-name search over people, nicknames and aliases; returns scored candidates. |
| Full record: facts, last 10 interactions, open reminders, open commitments both ways, days since last contact. |
| Compact conversation brief, recomputed after edits, with source ids and conflicting fact values flagged. |
| Create or update a person by id/exact name; all fields besides name are partial. |
| Attach a WhatsApp/e-mail/phone/other handle so future messages resolve to a person; idempotent. |
| Record a free-form key/value fact ("le gusta" / "el senderismo"); idempotent. |
| Log a contact and update when you last spoke. |
| Create a reminder, optionally tied to a person. |
| Mark a reminder done. |
| Birthdays, reminders due and neglected people within N days, with a one-line summary. |
| List people, optionally filtered by circle. |
| Commitments filtered by person, direction, status, overdue and due date. |
| Record a promise: I owe someone something, or they owe me. Words like "el viernes" become a date when they name one day. |
| Change text, day, person or direction of a commitment. |
| Mark a commitment fulfilled, or no longer applicable. |
| List the review queue, or settle one proposal (accept with a person, or discard). Asks the user first. |
| Ask Funes (through the hub) for a meeting's minutes and record their action items ( |
| Propose commitments from pasted text with the local model; they go to the review queue. |
| Record the promises in a batch of chat messages (WhatsApp): Spanish/English rules, days resolved from the message date, deduplicated by message id, person matched by phone or name; |
| What is overdue and due soon, in words: "le debes a X…", "X te debe…". |
| Log "Reunión: " on each attendee of a meeting who is in the book (minutes from Funes through the hub); reports |
| Read the hub's mail from the people in the book and keep date, channel and subject as their last contact (never a body); |
| Save a gift idea for a person (page and budget optional; the same idea is not saved twice) / list ideas for one person or everyone. |
| Ask Tantalus (through the hub) to watch a gift idea's price or stock and keep the watcher id. Reports |
| Likely duplicate pairs (similar names, the same e-mail or phone written differently, the same birthday) with score, reasons and which one to keep; read-only, |
| Merge a duplicate into another person; commitments move too (destructive). |
| Delete a person and everything linked to them; commitments stay with the name as written (destructive). |
Every description ends with a Sinónimos: line of Spanish words. Ambiguous names return candidates so the assistant can ask instead of guessing — two people can share a first name.
Data and limits
data/peoples-hoard.db— SQLite in WAL mode; schema migrations inserver/db.js(schema_versiontable); a hand-maintained FTS5 index (people_fts) over name, nickname, aliases, summary, notes and facts, with accent folding when the SQLite build supports it.data/mcp-token— 32 random bytes written at every start; never committed.Search combines a fold-based substring pass (catches mid-word partial matches and accents) with an FTS5 prefix pass for broader recall over notes/facts.
Requests are accepted only from
localhost/127.0.0.1origins; cross-site requests are rejected.An alias's
kind+valueis globally unique: one WhatsApp name, e-mail or phone number can only ever resolve to one person.The only network calls are to the local Hoard Link hub (events, the Funes minutes proxy, the local model, its mail gateway, Tantalus's watchers and the reference graph); when it is not there the app works as before and says so where it matters.
Verification
npm test # node --test tests/*.test.js — dates/folding, domain logic, HTTP API, agent auth and tools, commitments (with a fake hub)
npm run build # vite build → dist/Tests use temporary data directories and never touch data/.
Shared with the family (Hoard Link 0.8)
server/hoard-link.js and server/hoard-commons/ are vendored copies of the commons. People uses them for: spoken due dates (parseDue: weekdays, «en dos semanas», «fin de mes», «3 nov»; vague words such as «la semana que viene» stay as due_text), the .ics export (buildIcs: birthdays repeat yearly, all-day, with the same UIDs as before), accent folding, addDays / daysBetween, contact handle normalisation (normalizeEmail, normalizePhone: an alias keeps the value as typed and a comparison key aliases.norm, so Ana+news@Example.com and ana@example.com, or 600 11 22 33 and +34 600112233, are one handle, and one handle still belongs to one person; rows written before the key existed are filled in the first time they are looked at), the duplicate finder (nameSimilarity), the mail router for the last contact, the guard, the agent routes (/api/agent/*, one JSON error envelope { error, code?, issues?, candidates? }), the MCP bridge, the SPA server, the SQLite wrapper (BEGIN IMMEDIATE, savepoints, busy timeout, WAL checkpoint on exit), the background timers and the port and token helpers. The MCP token (<data>/mcp-token) is created once and kept across restarts; SIGINT / SIGTERM stop the background passes and close the database cleanly.
Layout
server/ app.js (Express), index.js (boot), db.js, people.js, aliases.js, handles.js, duplicates.js, facts.js,
interactions.js, reminders.js, commitments.js, whatsapp.js, commitments-poller.js, meetings.js, mailsync.js, gifts.js, agenda.js, background.js, hub-chat.js, upcoming.js, calendar.js, dates.js, text.js, routes.js,
agent-tools.js, mcp.js,
hoard-link.js + hoard-commons/ (vendored from Hoard Link 0.8: due dates, iCalendar, text and handle normalisation, guard, agent routes, MCP bridge, SPA, database, background timers, token and port helpers; do not edit)
client/ React 19 + Vite + Tailwind v4 (pages: Personas, Persona with its gift ideas, Agenda, Compromisos, Ajustes)
scripts/ launch.mjs, dev.mjs
tests/ node:test suitesLicense: MIT (see LICENSE).
This server cannot be deployed
Maintenance
Related MCP Connectors
Personal CRM operated by AI agents. Read relationship context before acting, write back after.
An AI-first personal CRM you run in natural language: contacts, reminders, notes, and more.
MemoryOracle - 10 agent memory tools: vector store, recall, summarization, redaction.
Email inboxes and calendars for AI agents: send, receive, search, draft and schedule.
Related MCP Servers
- AlicenseNot gradedqualityFmaintenanceEnables AI assistants to manage Folk CRM data, including contacts, companies, notes, and reminders. It supports searching for entities, logging interactions, and setting follow-up tasks through the Folk REST API.6MIT
- AlicenseNot gradedqualityDmaintenanceGives AI assistants access to Google Contacts, supporting listing, searching, creating, updating, and deleting contacts, as well as searching Google Workspace directories.MIT
- AlicenseNot gradedqualityFmaintenanceEnables AI assistants to manage contacts with full CRUD, dedup, merge, import/export, sync with Google/Apple/CardDAV, and git-backed rollback.7 npmAGPL 3.0
- AlicenseNot gradedqualityDmaintenanceEnables AI assistants to interact with local Apple Calendar, Notes, and Contacts on macOS. Provides tools for creating, reading, updating, and deleting events, notes, and contacts through natural language.MIT