Self-hosted Β· MIT

kabootar.

A personal assistant that lives in WhatsApp. No app to open, no tab to keep.

Kabootar (ΰ€•ΰ€¬ΰ₯‚ΰ€€ΰ€°) is Hindi for pigeon β€” the original messaging protocol.

Youremind me to call the plumber tomorrow at 9
AgentScheduled #4 for Fri 19 Sep 2026, 9:00 am GMT+5:30
Younote: the gate code at mum's building is 4471
AgentSaved note #12. three weeks later Youwhat's the gate code at mum's place?

What it does

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.

Dependencies
4
Containers
1
Database
1 file
API keys
1

Reminders that know where you are

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.

A whitelist, not a bot

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.

Notes you can find again

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.

What it does not do

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.

One process, one file

Everything runs in a single Node process against a single SQLite database. No broker, no vector store, no second container.

A message arrives from WhatsApp, passes a whitelist check, reaches the agent loop, which calls the language model and tools backed by SQLite. A scheduler polls the same database and sends reminders back out. WhatsApp baileys Β· linked device Whitelisted? checked before the model no dropped, silently yes Agent loop plan β†’ call tools β†’ reply OpenRouter any chat model Tools schedules Β· notes SQLite (WAL) messages Β· schedules Β· notes Scheduler polls every 30s reminders out reply
Inbound on the left of the spine, outbound up the sides.

Nothing is lost on a crash

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.

Reminders prefer duplicates to misses

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.

Isolation lives in one file

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.

Two seams

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.

Getting it running

You need
A spare numberThe agent logs in as a WhatsApp linked device. Use a number you can afford to lose.
An OpenRouter keyFree to create. Pay per token; personal use costs cents a day.
DockerOr Node 22.9+ to run from source. 512 MB of RAM is plenty.
  1. Clone and configure

    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.

  2. Start it

    docker compose up -d
    docker compose logs -f

    A pairing code appears in the log within a few seconds.

  3. Pair the device

    See the Pairing tab β€” it is the step most worth reading before you start.

  4. Add yourself

    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.

Configuration

VariableDefault
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.
TZUTCLeave it. The host zone is never used for scheduling.
PORT8080Health endpoint only.
MAX_STEPS8Tool calls per turn before it must answer in words.
HISTORY_LIMIT20Messages of context sent to the model.
Do not set 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.

Backup

data/kabootar.db is the entire system. Copy the file β€” VACUUM INTO is safe on a live database, unlike copying mid-write.

Pairing the device

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

On the phone holding WA_NUMBER:

WhatsApp β†’ Settings β†’ Linked Devices β†’ Link a Device β†’ β€œLink with phone number instead”, then enter the code.

Open that screen before you start. Codes expire in about a minute, and most reports of β€œthe code is correct but it was refused” are simply the code ageing out while you work through the menus.

Credentials are written to data/wa-auth, so every later restart reconnects without pairing again.

If the code is refused

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

Keep the phone online

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.

Baileys is an unofficial client, and WhatsApp may ban the number it logs in as. The trade was deliberate: the official Cloud API forbids free-form proactive messages outside a 24-hour window, which is exactly what a reminder is. Use a spare number, not your primary one.

Command line

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

Adding someone

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.

Removing someone

Removal deactivates the row rather than deleting it. Their notes and reminders survive, their pending reminders stop firing, and re-adding restores access.

From a chat message

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:

  • A non-admin asking is refused outright.
  • The chat tool cannot grant admin. Only the CLI can.

So the worst outcome of anything going wrong through chat is one extra ordinary user β€” not someone else controlling the whitelist.

What the agent can do

Tool
create_scheduleOne-off or recurring, in your local wall-clock time
list_schedules
cancel_schedule
Review and cancel pending ones
create_note
search_notes
delete_note
Save text and find it later
set_timezoneChange 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.

When something is wrong

The pairing code is correct but refused

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.

It connected, but my messages are ignored

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.

It stopped answering after a couple of weeks

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.

Reminders arrive at the wrong hour

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.

ENOENT: mkdir '/data' when running locally

You have DB_PATH=/data/... in .env. Remove it; that path only exists inside the container.

Nothing publishes to the container registry

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