Self-hosted Β· MIT
A personal assistant that lives in WhatsApp. No app to open, no tab to keep.
Kabootar (ΰ€ΰ€¬ΰ₯ΰ€€ΰ€°) is Hindi for pigeon β the original messaging protocol.
You message a WhatsApp number. It sets reminders, saves notes you can search later, and answers only to phone numbers you have explicitly allowed. Everything runs on your own server.
A 3 PM daily reminder for someone in Asia/Kolkata fires at 3 PM in India whether the server sits in Frankfurt, Virginia, or Mumbai. The host's own timezone is never read. Recurring reminders recompute in your zone after every send, so they hold their local time across a daylight-saving change instead of drifting an hour every spring.
Unknown numbers are dropped before their text ever reaches the model β and dropped in silence, because a refusal would confirm the number is live. Each allowed person gets their own notes and reminders; nobody can see anyone else's.
Search runs on SQLite's full-text index with stemming, so "meeting" finds "meetings". It matches words, not meaning: a note saying "scaling the team" will not surface for "org growth". In practice you search with the words you wrote.
Text messages only β voice notes, images, and PDFs are not processed yet, and the agent says so rather than failing quietly. There is no vector database, no message broker, and no object storage, each left out on purpose.
Everything runs in a single Node process against a single SQLite database. No broker, no vector store, no second container.
Every inbound message is written to SQLite before the agent runs, so a crash mid-reply is retried on the next boot rather than dropped. WhatsApp redelivers messages when a client reconnects; those are ignored by message id.
The scheduler sends first and marks sent afterwards. If the process dies in between, the reminder fires again. A repeated reminder is a much better failure than a silent one.
Every read and write of user-owned data goes through repo.ts, where forUser(id) closes over the account id. The database handle never leaves that module, so isolation is a property of one file rather than a rule every query has to remember.
WhatsApp and the language model each sit behind a single file exporting plain functions. Moving to Telegram, or to a local Ollama, means rewriting one file's body and nothing else.
| You need | |
|---|---|
| A spare number | The agent logs in as a WhatsApp linked device. Use a number you can afford to lose. |
| An OpenRouter key | Free to create. Pay per token; personal use costs cents a day. |
| Docker | Or Node 22.9+ to run from source. 512 MB of RAM is plenty. |
git clone https://github.com/hitenvats16/kabootar.git
cd kabootar
cp .env.example .env
Fill in .env:
OPENROUTER_API_KEY=sk-or-v1-...
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5
WA_NUMBER=919876543210 # the AGENT's number, digits only
TZ=UTC # leave this alone
WA_NUMBER is the number the agent logs in as, not yours.
docker compose up -d
docker compose logs -f
A pairing code appears in the log within a few seconds.
See the Pairing tab β it is the step most worth reading before you start.
docker compose exec kabootar node dist/cli.js \
whitelist add 919876543210 --tz Asia/Kolkata --name You --admin
Use the number you will message from. Until you do this, the agent ignores everyone β including you.
| Variable | Default | |
|---|---|---|
OPENROUTER_API_KEY | β | Required. The only credential. |
OPENROUTER_MODEL | β | Required. Pick one that is good at tool calling. |
WA_NUMBER | β | Required to pair. Digits only, with country code. |
TZ | UTC | Leave it. The host zone is never used for scheduling. |
PORT | 8080 | Health endpoint only. |
MAX_STEPS | 8 | Tool calls per turn before it must answer in words. |
HISTORY_LIMIT | 20 | Messages of context sent to the model. |
DB_PATH or WA_AUTH_DIR in .env. The image points them at /data and local runs default to ./data. Setting them breaks one or the other.data/kabootar.db is the entire system. Copy the file β VACUUM INTO is safe on a live database, unlike copying mid-write.
The agent connects the same way WhatsApp Web does β as a linked device. It pairs by code rather than by QR, because a QR code in docker logs over SSH refreshes every twenty seconds and needs a wide enough terminal to render at all.
Start the container and watch the log. This appears:
X1G5-NBMWOn the phone holding WA_NUMBER:
WhatsApp β Settings β Linked Devices β Link a Device β βLink with phone number insteadβ, then enter the code.
Credentials are written to data/wa-auth, so every later restart reconnects without pairing again.
A refused attempt leaves partial credentials behind that will fail every retry. Clear them first:
docker compose exec kabootar node dist/cli.js pair
docker compose restart
WhatsApp drops a linked device if the paired phone stays offline for around fourteen days. Since WhatsApp is the only channel, the agent cannot tell you over WhatsApp that WhatsApp is down β point an uptime monitor at GET :8080, which returns {"whatsapp":"connected"} or a 503.
From a clone, prefix with npm run cli --. In Docker, use docker compose exec kabootar node dist/cli.js.
whitelist list
whitelist add <phone> --tz <IANA> [--name <name>] [--admin]
whitelist remove <phone>
pair # clear the session, re-pair on next start
npm run cli -- whitelist add 919876543210 \
--tz Asia/Kolkata --name Priya
The -- matters: without it, npm swallows the flags.
--tz is required and has no default. A wrong timezone means reminders arrive at the wrong hour, so there is no safe value to guess.
Removal deactivates the row rather than deleting it. Their notes and reminders survive, their pending reminders stop firing, and re-adding restores access.
Admins can add and remove numbers by message β βadd +91 98765 43210 to the whitelist, she's in Kolkataβ β so routine changes need no shell access. Two limits are enforced in code rather than in the prompt:
So the worst outcome of anything going wrong through chat is one extra ordinary user β not someone else controlling the whitelist.
| Tool | |
|---|---|
create_schedule | One-off or recurring, in your local wall-clock time |
list_schedulescancel_schedule | Review and cancel pending ones |
create_notesearch_notesdelete_note | Save text and find it later |
set_timezone | Change the zone new reminders are read in |
whitelist_* | Admin only |
Recurring reminders take a cron expression, so βevery weekday at 9amβ becomes 0 9 * * 1-5.
Usually expiry β have Linked Devices open before you start. Otherwise run cli pair to clear the partial credentials, then restart. A refused attempt poisons the next one.
Run whitelist list. The number must carry its country code and match the number you are messaging from β 918766250132, not 8766250132. Unknown numbers are dropped in silence by design, but the log tells an unrecognised number apart from one it could not resolve.
WhatsApp drops a linked device when the paired phone stays offline for around fourteen days. Bring that phone online, or re-pair. This is what the health endpoint is for.
That account's timezone is wrong. Fix it with set_timezone or re-add the number. Existing reminders keep the zone they were created in, by design β travelling should not silently move reminders you already set.
You have DB_PATH=/data/... in .env. Remove it; that path only exists inside the container.
CI builds on main. Also check the package's visibility: the first push creates it as private even in a public repository, and pulls fail with an auth error rather than a clear "not found".