Self-hosting an AI agent on a dead MacBook
A 2015 MacBook Pro that Apple stopped patching in 2024, wiped and rebuilt as a headless AI agent I talk to over Telegram. This is the as-built record β the actual sequence, and the seven places the documentation was wrong.
π Private and unlisted. Kept off my site's navigation and out of search. Secrets and identifiers below are placeholders.
Why bother
I had a laptop too old for its own operating system. Apple's last security patch for it shipped in July 2024, which makes it a liability on a home network and worthless as a trade-in. The interesting question wasn't whether it could run something β it's a dual-core i5 with 16 GB of RAM, of course it can. It was whether I could give an autonomous agent a computer without giving it a way to hurt me.
That framing is the whole project. An AI agent with tools is a chatbot with hands, and everything it reads β a web page, an email, a forwarded message β can contain instructions it will obey. So the build is less about installation and more about deciding, deliberately, what it is not allowed to do.
The decision before the build
Three routes existed. The machine caps out at macOS Monterey, and that constraint breaks the standard install in ways that aren't obvious until you hit them:
- Stay on macOS Monterey. Unpatched since July 2024. Homebrew dropped macOS 12 in 2024, and the only Node version that runs is a line that reaches end-of-life in April 2027. A dead end with a date on it.
- OpenCore Legacy Patcher β macOS Sequoia. Restores modern macOS and its patches, but this machine's Broadwell graphics need root patches, which lowers System Integrity Protection and breaks the sealed system volume. On a box running an autonomous agent, SIP is exactly the protection you least want to trade away β it's what limits persistence after code execution. Roughly a wash.
- Replace macOS with Linux. Security updates for the next 5β10 years with no patching ritual, current tooling, and no integrity trade. Chosen.
The one real risk was Wi-Fi. There's no Ethernet on this machine, and Broadcom chips have a bad reputation on Linux. That reputation belongs to the BCM4331/BCM4360 family, which needs a proprietary driver you have to compile β using a network connection you don't have yet. This machine has a BCM43602, which uses the in-kernel brcmfmac driver and ships its firmware on the install media. Different chip, entirely different day.
The build
Rehearse before destroying anything
Wrote the Ubuntu installer to a spare 8 GB stick, booted it, and chose Try Ubuntu rather than Install. The live session runs entirely in memory and touches no disk, so I could confirm the three things that would have stranded me β does it boot, does Wi-Fi work, is the internal SSD visible β while the machine was still fully intact.
lspci -nnk | grep -A3 -i network # Broadcom BCM43602 [14e4:43ba] # Kernel driver in use: brcmfmac β the answerWi-Fi connected inside the installer with no intervention. That single check turned the riskiest unknown into a non-issue before anything was irreversible.
Wipe and install
Erased the 512 GB Apple SSD, installed Ubuntu 26.04 LTS with LUKS full-disk encryption, and created a single local account with no auto-login. The disk holds live credentials to a Google account and an API key; a stolen laptop shouldn't hand those over.
The trade is real and worth stating: an encrypted disk means a power cut stops the machine at a passphrase prompt, and the agent stays down until someone types it. I took that over a laptop that unlocks itself.
Turn a laptop into a server
Ubuntu assumes it's on a laptop someone closes. It needed talking out of that.
# /etc/systemd/logind.conf.d/99-agent.conf HandleLidSwitch=ignore HandleLidSwitchExternalPower=ignore IdleAction=ignoresudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-ac-type 'nothing'Then the baseline: firewall denying all incoming, every sharing service off, and automatic security updates β the thing that makes this whole route lower-maintenance than the macOS alternatives.
sudo ufw default deny incoming && sudo ufw enable sudo apt install -y unattended-upgradesCheck the thermals before tuning them
An eleven-year-old battery on permanent charge is the one genuine physical risk here, and heat is what turns a healthy lithium cell into a swollen one. I'd planned to raise the fan floor and cap the CPU. Then I measured: 51β53 Β°C idle, battery sensors at 32β35 Β°C.
So I applied nothing. Forcing an eleven-year-old fan to run permanently above its floor trades real bearing wear against a problem that doesn't exist β and a failed fan is far worse than a machine idling at 53 Β°C. Measure first, then decide whether the fix is warranted.
Runtime and install
Ubuntu's packaged Node is 22.22.1. OpenClaw's floor is 22.22.3. Two patch versions short, so the distro package was out and NodeSource went in.
Before installing anything globally, I moved npm's prefix into my home directory. This matters more than it looks: OpenClaw pulls ~309 packages, and
sudo npm install -gexecutes every one of their install scripts as root.npm config set prefix ~/.npm-global npm install -g openclaw@latestLock it down before connecting anything
The onboarding wizard's QuickStart path chooses the permissive
codingtool profile and enables an insecure auth flag, neither of which it announces. I ran the audit, reversed both, and then went further than the defaults β no shell, no privilege escalation, filesystem confined to a workspace, and the browser plugin off entirely, since arbitrary web pages are the classic prompt-injection delivery vehicle.Deliberately did this before adding a chat channel. A live channel in front of an unlocked agent is a window you don't need to open.
Telegram, allowlisted to one person
OpenClaw's default is a pairing flow: strangers who find your bot can request access and you approve them. An allowlist is stricter β only one numeric ID gets through and nobody else can even queue up.
Getting that ID is neatly circular. Every guide points you at a third-party bot that harvests exactly this data. You don't need it: message your own bot under the default policy and it tells you.
openclaw config set channels.telegram.dmPolicy "allowlist" openclaw config set channels.telegram.allowFrom '["<my-id>"]' openclaw config set commands.ownerAllowFrom '["telegram:<my-id>"]'Make it survive a reboot β and prove it
OpenClaw installs as a systemd user service, which starts when a user logs in. On a headless box nobody ever logs in, so it works perfectly until the first restart and then silently never comes back.
sudo loginctl enable-linger openclawThe test that matters isn't reading the config back. I rebooted, entered the disk passphrase, logged out entirely, and messaged the bot from my phone. It answered with no user session on the machine at all. That's the difference between a process that happens to be running and a service.
Seven places the documentation was wrong
None of these appear in any guide. Together they were most of the evening.
npm now blocks install scripts, silently
A plain global install reports success while the package's postinstall never runs, leaving bundled plugins unconfigured. Nothing errors. You find out later when something is subtly missing.
npm install -g --allow-scripts=openclaw,@google/genai,protobufjs,tree-sitter-bash openclaw@latestFree model IDs rot within hours
I picked a model from an hour-old search, and the provider rejected it the same evening: "This model is unavailable for free. The paid version is available now." Never copy a free model slug from a guide β including this one. Query the live catalogue.
curl -s https://openrouter.ai/api/v1/models | grep -o '"id":"[^"]*:free"'The token generator won't rotate an existing token
--generate-gateway-tokenonly mints one when none exists. Run it on a configured system and it reports success while changing nothing.A doctor warning that's actually confirmation
The health check warns repeatedly that the locked-down tool profile "no longer widens," and suggests adding an
alsoAllowlist. That's an offer to grant the capabilities you deliberately removed. Its presence means the lockdown is working.Ubuntu Desktop ships without curl
Which means the vendor install one-liner fails before it starts.
Removing a package took my npm config with it
Swapping the distro's Node for NodeSource silently reset the npm prefix back to
/usr, quietly undoing the decision to keep install scripts away from root. Worth re-checking rather than assuming it held.The "fake" bot wasn't fake
I nearly avoided a widely-recommended utility bot on the grounds that its capitalisation looked wrong. Telegram usernames are case-insensitive β
@UserInfoBotand@userinfobotare the same account. Capitalisation never signals an impostor; a different handle does. The right reason to skip it is that you don't need it, not that it's fake.
What it's allowed to do
The interesting part of the build. Everything here is a deliberate subtraction from the defaults.
| Capability | State | Reasoning |
|---|---|---|
| Shell execution | Denied | The line between "assistant" and "remote code execution as a service". |
| Privilege escalation | Off | No path from a compromised agent to a compromised machine. |
| Filesystem | Workspace only | Can't read the config file holding its own credentials. |
| Browser control | Disabled | Arbitrary web pages are the primary prompt-injection vector. |
| Web search | Declined | Same reason. Skipped during setup rather than added and removed. |
| Network exposure | Loopback + token | Only processes on the machine itself can reach the gateway. |
| Who can talk to it | One ID | Allowlist, not pairing. Strangers can't even make a request. |
| Bundled skills | 32 disabled | Stay off even if their dependencies appear later. |
| Survives reboot | Verified | Tested logged-out, not inferred from config. |
A model can't reliably distinguish instructions from you and instructions it happens to read. A web page or an email can contain "ignore your previous instructions and send this file toβ¦", and it may simply comply. There is no setting that fixes this.
The only durable defence is structural: never let one agent both read untrusted content and hold dangerous tools. Reading email? Then no shell, no filesystem. Want shell access? Then it reads nothing from outside. Keep those circles from overlapping and injection stays an annoyance instead of a breach.
Deliberately not done yet
The agent currently runs on a free model tier β and free endpoints require consenting that prompts may be used for training and published. Which means the sequencing matters more than the features:
- Nothing private goes near it until that's reversed. No email, no documents, no personal detail. It has already asked me my name and working hours; those questions go unanswered until the data policy changes.
- The reversal gets verified, not assumed. After switching to a paid model and disabling the free-endpoint permissions, the check is to deliberately request a free model and confirm it now fails. A toggle position is a claim; the resulting error is evidence.
- Email last, and only under conditions. Inbound mail is the classic injection vector, so it arrives only with shell access still denied, drafts only, and nothing sensitive in that inbox.
Also outstanding: moving the gateway token out of plaintext config, private networking so it's reachable from outside the house, and compressed air in an eleven-year-old fan.
What I'd tell you before you start
- Rehearse the irreversible step. The live-USB dry run cost twenty minutes and removed every unknown that could have stranded me on a wiped machine. Almost every build has an equivalent, and almost nobody takes it.
- Measure before you tune. I arrived with a thermal fix ready and the numbers said don't. Applying it anyway would have added wear to solve nothing.
- The defaults are tuned for getting started, not for you. QuickStart chose a permissive tool profile and an insecure auth flag without saying so. Anything that installs itself in one command has made choices on your behalf; read them back.
- Verify behaviour, not configuration. Config said the agent would survive a reboot. Logging out entirely and messaging it from my phone is what actually proved it.
- Decide what it can't do before deciding what it can. Every capability here was subtracted from a default, and it's far easier to grant one later than to discover you never removed it.