---
title: Start here — an always-on machine and an assistant that messages you
series: Build It Yourself — briefs
brief: 00
license: CC BY 4.0
tested_on: Claude Code (Aug 2026) · Node 22 · macOS 15 · Telegram Bot API (Aug 2026)
effort: one evening (2–3 h), much of it waiting for downloads
---

# Start here

## TL;DR
Every other brief in this series quietly assumes two things you may not have: a computer
that never sleeps, and a chat thread where your assistant can reach you and you can answer
back. This brief builds exactly that, in one evening, using **Telegram** — ten minutes, no
business account, no QR pairing, no credit card. You end up with a `notify` command any
script on the machine can call, and a small bridge that pipes your replies into Claude Code
and sends the answer back. Nothing downstream is Telegram-specific: from here on, every
brief only needs "send me a line" and "a reply comes back".

---

## What you get (a scene)

> *(07:00 — a script on the machine, no human involved)*
> **Assistant:** ☀️ Two meetings today, first at 10:00. Bins go out tonight.
>
> *(19:40 — you, from the sofa)*
> **You:** how much disk is left on you?
> **Assistant:** 81 GB free of 500 (84% used). The photo sync folder is the big one.
>
> *(Tuesday 10:00 — a watcher script you build in brief 01)*
> **Assistant:** ❗ A spot opened Thursday 18:00 — book it?
> **You:** yes
> **Assistant:** ✅ Booked.

That third scene is the point. Once the machine can talk to you *and hear you*, every other
brief in the series is just a script plus a good reason to speak.

---

## Prerequisites

- **A computer you can leave on.** An old laptop that stays plugged in, a mini PC, a
  Raspberry Pi 4/5, or a cheap VPS. It does not need to be fast — this is a scheduler
  making a few HTTP calls. One caveat: some later briefs touch your home network or
  smart-home devices, and a VPS is not in your house.
- **Telegram on your phone.** Free.
- **Node 20+ and Claude Code** installed on that machine.
- Two or three hours, no credit card, no domain, no public IP, no Docker.

---

## Why Telegram first (and what it costs you)

| | Telegram bot | WhatsApp (Web pairing) | WhatsApp Cloud API |
|---|---|---|---|
| Time to first message | ~10 min | an hour, if it goes well | days (business verification) |
| Cost | free | free | free tier, then per-conversation |
| Fragility | very low — an HTTP API with a token | a paired browser session that can drop | low, but rule-bound |
| Catch | it's a bot: you must message it first, and it wears a bot badge | restart the gateway twice inside half an hour and the channel gets blocked for the day | you may only message a person inside 24 h of *their* last message, otherwise pre-approved templates only |

We run WhatsApp because the household already lives there, and we paid for that in
outages. If you are starting today, start on Telegram: it is one function call
(`send(text)`), and swapping it for WhatsApp later is an afternoon, not a rewrite.

Other channels that work the same way, if you prefer: Discord webhook, Slack app, `ntfy`
push, or plain email. Same shape — a `notify` script and something that reads replies.

---

## Architecture (the 15-line version)

```
 your phone (Telegram) ⇄ Telegram Bot API ⇄ bridge.js  (long-poll getUpdates, outbound only)
                                              │  allow-list: ONLY my chat id
                                              ├──▶ claude -p --resume <session>   (shell tools)
                                              └──▶ sendMessage ◀── the answer
 cron / launchd / systemd ──▶ your scripts ──▶ notify.sh "text" ──▶ sendMessage
 keep-awake + start-on-boot ─────────────────────────────────────────┘
 daily heartbeat ──▶ "🟢 alive, 3 jobs ran" ──▶ you   (the thing that catches silent death)
```

1. **`notify.sh` is the whole platform** for 90% of what you will build. One argument, one
   message, exit code you can trust.
2. **`bridge.js` long-polls.** No inbound port, no tunnel, no certificate — the machine
   calls out and holds the connection open. This is why it works behind any home router.
3. **Claude Code runs headless** (`claude -p`) with a session per chat, so the conversation
   remembers itself.
4. **Everything else is cron.** Scripts do the work on a dumb schedule; the model is only
   in the loop where judgment lives.

---

## Build steps — paste each block into Claude Code, in order

### Step 0 — make the machine actually always-on (20 min, no code)

Do this first; it is the part people skip and then debug for a week.

- **macOS:** `sudo pmset -c sleep 0 disksleep 0 displaysleep 10` (on charger: never sleep,
  screen off after 10), `sudo pmset -a autorestart 1` (come back by itself after a power
  cut), `sudo pmset -a womp 1`. A closed laptop lid still sleeps unless it is on charger
  with an external display — for a lid-down machine keep a `caffeinate -s` service running.
- **Linux / Raspberry Pi:** `sudo systemctl mask sleep.target suspend.target hibernate.target`,
  and turn off Wi-Fi power saving (`iw dev wlan0 set power_save off`) or the machine
  answers slowly at 4 a.m.
- **Decide the disk-encryption question on purpose.** With full-disk encryption on
  (FileVault, LUKS), *nothing runs after a reboot until a human types the password* — a
  power cut at 03:00 means a dead assistant until someone comes home. We lost 32 hours to
  exactly that, and now run the household machine unencrypted-but-physically-secure, with
  secrets in the OS keychain instead. Choose one, knowingly.
- **Test it like you mean it:** `sudo shutdown -r now`, walk away, come back in five
  minutes and check that everything is running without you having touched the keyboard.

### Step 1 — the bot (10 min, phone + terminal)

1. In Telegram, message **@BotFather** → `/newbot` → pick a name and a username → he
   replies with a **token**. That token *is* the bot; treat it like a password.
2. Send your new bot any message ("hi") from your own account.
3. On the machine, fetch the chat id:
   `curl -s "https://api.telegram.org/bot<TOKEN>/getUpdates" | grep -o '"chat":{"id":[-0-9]*'`
   The number is **your chat id** — the only one the bridge will ever answer.
4. Put the token in the OS secret store, not in a file next to your code:
   - macOS: `security add-generic-password -a "$USER" -s tg-bot-token -w` (it prompts, so
     the token never lands in your shell history)
   - Linux: `pass insert tg-bot-token`, or a `chmod 600` file outside the repo.

**Check:** you can send yourself a message from the terminal:
```
curl -s -X POST "https://api.telegram.org/bot<TOKEN>/sendMessage" -d chat_id=<ID> -d text=hello
```

### Step 2 — `notify.sh` (the one thing every other brief calls)

```text
Create ~/agent/notify.sh (bash, POSIX-ish, no deps beyond curl):
- Reads the bot token from the OS secret store at call time (macOS: security
  find-generic-password -s tg-bot-token -w; Linux: pass show tg-bot-token). NEVER take the
  token as an argument and never echo the request URL — the token lives in the URL path,
  so a logged URL is a leaked bot.
- Pass the URL to curl through `curl --config -` on stdin (a heredoc with `url = "..."`),
  NOT as a command-line argument: an argument is visible to anyone who can read the
  process list while the send is in flight.
- Reads TG_CHAT_ID from ~/agent/agent.env.
- Message text from "$1", or from stdin when there are no arguments. Stop parsing flags at
  the first non-flag argument, so an answer that happens to begin with "--" is still text.
- POST to https://api.telegram.org/bot$TOKEN/sendMessage with form fields chat_id and text.
  No parse_mode by default — a stray _ or * in a filename must never turn a send into a 400.
- Split anything longer than 4000 characters into multiple sends, in order.
- Flags: --quiet (disable_notification=true) and --admin (send to TG_ADMIN_CHAT_ID if set,
  else fall back to the main chat and say so).
- Error handling: if the JSON says ok:false, print Telegram's "description" and exit 1. On
  HTTP 429, sleep the retry_after seconds and try once more. A failed send must be a
  non-zero exit — silent failure is the enemy of every job you will build later.
- On success append ONE line to ~/agent/sent.log: ISO timestamp, message_id, first 60
  chars of the text. That file is the artifact that proves delivery; a monitor will read it.
```

**Check:** `~/agent/notify.sh "hello from the machine"` arrives on your phone in a second;
`sent.log` has the `message_id`; `echo "piped" | ~/agent/notify.sh` works; a 5000-character
message arrives as two; a deliberately wrong chat id exits non-zero and prints Telegram's
own error, not a generic one.

### Step 3 — the bridge (your replies reach Claude Code)

```text
Create ~/agent/bridge.js (Node 20+, no dependencies — global fetch is enough):
- Long-poll loop: GET https://api.telegram.org/bot$TOKEN/getUpdates?offset=<n>&timeout=50.
  Persist the new offset to ~/agent/state.json BEFORE handling the message, so a crash
  mid-answer can never make the bot reply to the same message forever.
- Allow-list: ignore every update whose message.chat.id !== TG_CHAT_ID, and log one line
  "ignored chat <id>". Bot usernames are publicly searchable; strangers do wander in.
- For an allowed text message: send the "typing" chat action, then run Claude Code headless
  with execFile (never a shell string):
    claude -p "<the message text>" --output-format json --allowed-tools 'Bash,Read,Grep,Glob'
    --max-turns 20 [--resume <sessionId>]
  cwd ~/agent, timeout 120 s. Keep the returned session id per chat in state.json and pass
  --resume next time, so a follow-up question remembers the previous answer.
  (Headless Claude Code will happily run shell commands without an allow-list — pass one
  anyway, deliberately: this flag is the boundary of what a message from your phone can do
  to the machine, and it is the only place that boundary is written down.)
- Reply by calling notify.sh with the result text (one sender, one log, one retry policy).
- On timeout or a non-zero exit: reply "⚠️ that failed: <first line of stderr>". Never
  leave a message unanswered — silence is indistinguishable from a dead machine.
- Serialize: handle one message at a time (a simple in-process queue). Two concurrent
  claude runs resuming the same session will corrupt the conversation.
- Survive the network: any fetch error → log, wait 5 s, continue the loop forever.
- Log to ~/agent/bridge.log with timestamps; keep it under a few MB (truncate on start).
```

**Check:** ask "what time is it on you?" → correct local time. Ask "how much disk is free?"
→ it actually runs `df` and tells you. Then ask "and how much of that is the home folder?"
— if it answers in context, `--resume` is wired right. Message the bot from a second
account: nothing happens, and `bridge.log` has one "ignored chat" line.

> ⚠️ **Read this before you leave it running.** An assistant with shell tools on your
> always-on machine is exactly as powerful on that machine as you are. Keep the chat
> allow-list to your own id, start with a narrow tool allow-list and widen it deliberately,
> and never put the bridge behind a public URL. Long polling means there is no inbound
> port at all — that is a feature, not an accident.

### Step 4 — make both survive a reboot (20 min)

```text
Write the service definitions for my OS:
- macOS: a launchd plist per service (bridge, and later your cron scripts) in
  ~/Library/LaunchAgents, RunAtLoad + KeepAlive, absolute paths to node and to the script,
  stdout/stderr to ~/agent/logs/. Load with launchctl bootstrap gui/$UID <plist>.
- Linux: a systemd --user unit with Restart=always and RestartSec=5, plus
  `loginctl enable-linger $USER` so it runs without a login session.
Environment (TG_CHAT_ID, PATH) belongs in the unit/plist or a small wrapper that reads the
secret store — never the token itself in the plist.
```

**Check:** `sudo shutdown -r now`, wait, then message the bot from your phone without
touching the machine. If it answers, you have an assistant.

**Trap while you are here:** on macOS, `launchctl kickstart -k` restarts the process with
the *old* job definition — after editing environment in a plist you must `bootout` and
`bootstrap` again, or you will debug a change that never loaded.

### Step 5 — the heartbeat (10 min — the step that saves you)

```text
Add ~/agent/heartbeat.sh, run daily at 08:00 by cron/launchd/systemd-timer:
- count lines added to sent.log in the last 24 h and the age of the newest line
- send ONE line: "🟢 alive — <n> messages sent, last <h>h ago, uptime <d>d"
- if the newest sent.log line is older than 36 h, mark it 🔴 and say which job is quiet
```

Every job you add from now on gets the same treatment: it names an artifact that proves it
*delivered* — a state file it touches, a line it appends — and something checks the age of
that artifact. "The script ran" is not "you were told".

---

## The traps we hit (read these twice)

**1. Green is not delivered.** Our worst outages were jobs that ran perfectly and told
nobody: a lane exited 0 every two hours for weeks with nothing to say, and everything on
the dashboard was green. *Fix:* step 5. An exit code proves the script ended, not that a
message arrived. Check artifacts, not statuses.

**2. The offset is not optional.** Skip `offset` (or write it after processing) and every
crash makes your bot re-answer the last message on the next start — sometimes in a loop,
at your expense. Persist it *before* you handle the message.

**3. A restarted bridge answers your backlog.** Telegram queues undelivered updates for
about a day, so the first thing a bridge that was down all afternoon does on startup is
work through every message you sent meanwhile — we watched it answer a "Hey" from half an
hour earlier, which is charming once and alarming after a week of downtime. *Fix:* at
startup, drop updates whose `date` is older than a few minutes.

**4. Anyone can find your bot.** Bot usernames are searchable and people do message them
at random. Without a chat-id allow-list, a stranger gets a shell-enabled assistant on your
home machine. This is the single most important line in the bridge.

**5. The token is in the URL — and therefore in the process list.** Telegram puts it in
the request path by design. "Don't log the URL" is the obvious half; the half that bit us
while writing this brief is that `curl "https://api.telegram.org/bot<TOKEN>/sendMessage"`
puts the token in the command's *arguments*, where anyone able to read the process table
can lift it mid-send (on Linux, `/proc/<pid>/cmdline` is world-readable by default). Feed
the URL to curl on stdin with `--config -`. Same family of mistake as a secret in a query
string: we once found our own hub key sitting in an access log 28 times.

**6. Don't schedule with the LLM.** It is tempting to let the assistant "check every two
hours". An agent turn costs money, takes tens of seconds, and can deadlock when the script
it runs calls back into the same runtime. Cron runs scripts; the model only judges.

**7. The machine's clock is not your clock.** A VPS in UTC will fire your 09:00 job at
11:00 local. Set the timezone deliberately (`timedatectl` / `sudo systemsetup -settimezone`)
and, when a scheduler offers "hour 9", check which hour it means before you trust it.

**8. A closed laptop is not an always-on machine.** Sleep, Wi-Fi power saving, and "on
battery" power profiles will each silently pause your assistant for hours. Step 0 exists
because we did all three.

**9. Full-disk encryption blocks unattended recovery.** Nothing starts after a power cut
until a human unlocks the disk. Decide before you need it, not at 03:00.

**10. `parse_mode` is a footgun.** Send a filename with an underscore as Markdown and
Telegram rejects the whole message with a 400. Default to plain text; opt into formatting
per message, once you have a reason.

**11. 4096 characters.** Long outputs get truncated or rejected. Chunk in the sender, once,
so no caller ever has to think about it.

**12. launchd and systemd have no PATH.** `node: command not found` at 03:00 from a job
that works perfectly in your terminal. Use absolute paths, or set PATH in the unit.

**13. Two bridges = two replies.** The copy you started in a terminal and the one the
service manager started are both long-polling. Use a lockfile (or check for a running pid
on start) and let the service be the only owner.

**14. Failure must be loud somewhere, quiet with you.** Errors going to your main chat
train you to ignore your assistant. Make a second Telegram chat (or a group with just you)
the admin channel, send failures there, and keep the main thread for things a human should
act on.

---

## You know it works when

- [ ] `notify.sh "test"` arrives in under two seconds and appends a line to `sent.log`.
- [ ] A wrong chat id makes `notify.sh` exit non-zero and print Telegram's own error text.
- [ ] You reboot the machine, touch nothing, and the bot answers a question from your phone.
- [ ] A follow-up question ("and how big is that folder?") is answered in context.
- [ ] A message from another account is ignored, with one line in the log.
- [ ] You unplug the network for a minute; the bridge reconnects by itself and answers the
      message you sent while it was down.
- [ ] The 08:00 heartbeat arrives, and if you deliberately break `notify.sh`, tomorrow's
      heartbeat is the thing that tells you.

---

## Extend it

- **A second person:** their own chat id, their own allow-list entry, their own thread.
- **Voice notes in:** Telegram delivers voice as OGG — download the file, transcribe it
  locally or with an API, and feed the text to the same bridge.
- **Swap the channel:** rewrite one function inside `notify.sh` and the whole system moves
  to WhatsApp, Discord, or email. This is the reason it is one script.
- **Move to a framework** when you want channels, sessions, skills, and tool policies you
  did not write yourself (we use OpenClaw, an open-source agent gateway). The concepts in
  this brief map one-to-one; you keep the traps.
- **Then build something.** Brief 01 (a booking-slot watcher that proposes and books) and
  brief 02 (reminders that chase you until it's done) both start exactly where this brief
  ends.

## Changelog

- 2026-08-17 — first public version. Before publishing we pasted steps 2 and 3 into a fresh
  Claude Code session exactly as written above and ran the result against a real bot: both
  scripts worked on the first pass, and traps 3 and 5 are things that test taught us. The
  Linux paths (`pass`, systemd, `/proc`) are from our own systems, not from this run —
  macOS is what we verified end to end.

---

*Part of **Build It Yourself** — free briefs on building a personal AI Chief of Staff, one
capability at a time: https://chief.natalicot.com/briefs · License CC BY 4.0. Free, forever.*
