Persistent AI Assistants: Architecting Always-On Digital Buddies for the Personal Compute Era

Persistent AI Assistants: Architecting Always-On Digital Buddies for the Personal Compute Era

Always-on personal compute is back — Mac minis humming on shelves, tiny servers under the TV, VPSes nobody asked for. They could be more than file dumps. This 5,000-word investigation surveys the patterns developers commonly use to put AI agents on always-on hardware, exposes the failure modes of each, and presents a production-tested architecture that turns your idle box into a digital buddy who works while you’re at dinner with your family.


The Quiet Return of Always-On Personal Compute

A small revolution has been unfolding on shelves and under desks. The desktop machine that sat dormant when you weren’t at it — that’s becoming a thing of the past. On October 29, 2024, Apple announced the M4 Mac mini — the first redesign of the line in over a decade, in a 5×5-inch form factor, starting at $599. Apple reported Mac segment net sales of $33.7 billion in fiscal 2025, up from $29.98 billion the year before, a roughly 12% year-over-year increase. The redesign was followed by a wave of Reddit, YouTube, and Hacker News threads documenting “I leave my mini running 24/7” rigs for ML inference, home automation, and always-available development. Hetzner, OVH, and other VPS providers continue to advertise dedicated developer-targeted boxes for the price of a couple of dinners a month.

This hardware is everywhere. The question is what to do with it. From voice assistants that only work when you remember to invoke them, to dashboards that demand you remember to check them, the always-on box has been criminally underused. It should be doing work for you, not waiting to be asked.

Below, we examine the technical patterns developers have been reaching for to put AI agents on this hardware, dissect their failure modes, and provide a roadmap for a production-grade alternative.


The Personal AI Assistant Gap

The global AI agents market is projected to reach $50.31 billion by 2030, growing at a 45.8% CAGR according to Grand View Research, with comparable forecasts from MarketsandMarkets ($52.62 billion at 46.3% CAGR) and BCC Research ($48.3 billion at 43.3% CAGR). But this growth is heavily concentrated in cloud-hosted, web-accessed assistants. For developers wanting their own assistant — running their own tools, on their own machine, in their own networks — the tooling has lagged.

The consequences compound. An assistant that forgets isn’t really an assistant — it’s a stateless function call dressed in a chat UI. An assistant that can only act when you’re at the keyboard isn’t reclaiming your time — it’s just relocating it. And an assistant that needs --dangerously-skip-permissions to do anything useful isn’t safe — it’s a footgun pointed at your home directory.

What an Always-On AI Buddy Actually Has to Do

Most existing patterns tackle one or two of these requirements. Few tackle all five:

  1. Persistent context: A buddy that remembers the file you discussed yesterday
  2. Proactive work: An agent that runs builds, edits repos, and watches deploys while you’re away
  3. Safe autonomy: Permission gates that allow safe operations and ask before destructive ones
  4. Reachable surface: A chat interface that’s already in your pocket
  5. Resilient lifecycle: Survives reboots, network blips, and accidental kills

Common Patterns and Their Limits

1. The Polling Daemon (The Most Common Mistake)

Architecture:

Observed Failures:

Real-World Cost: In a polling daemon I deployed in early 2026, every single message triggered a fresh claude invocation — many spawns per active day, each costing seconds of latency, a fresh model warm-up, and a complete reload of MCP servers. Conversation continuity: zero.


2. Custom Bot Framework + Direct API

Approach:

Trade-offs:

Cost-Benefit: Justified only when you need control over agent behavior at a level Claude Code doesn’t expose — multi-tenancy, custom tool gating, regulatory compliance contexts. For most personal-assistant use cases, this is a YAGNI violation.


3. Bare Interactive claude --channels (The Closest Miss)

Approach:

Why It Almost Works:

Why It Fails in Production:

Diagnosis: The shape is correct; the plumbing is missing.


Deep Technical Analysis: The Persistent Bot Architecture

The architecture that addresses all five gaps is, in essence, Pattern 3 with proper plumbing. The key insight: you don’t have to build a chat bot at all. You have to keep Claude alive and out of the way. Everything else is upstream.

Architectural Overview

1
2
3
4
5
6
7
[init system (systemd/launchd/etc.)]
   └── supervisor (~120-line watchdog)
         └── tmux session (persistent, attachable)
               └── claude --resume <uuid> --channels plugin:<chat>@<marketplace>
                     ├── chat plugin MCP server (WebSocket gateway, permission protocol)
                          └── chat platform (Discord / Telegram / Slack)
                     └── conversation autosaved to ~/.claude/projects/<cwd>/<uuid>.jsonl

Component Responsibilities

Best Practice:


The Supervisor: Smallest Useful Watchdog

The supervisor’s loop is a single-threaded, side-effecting routine. The conceptual core:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
SESSION = "your-bot"

def main():
    install_signal_handlers()  # SIGTERM → set _shutdown event
    if not has_session():
        start_session()  # tmux new-session -d -s <name> -- <claude argv>
    sd_notify("READY=1")

    while not _shutdown.is_set():
        try:
            if not has_session():
                start_session()
            elif not claude_alive():
                respawn_claude()  # tmux respawn-pane -k -- <claude argv>
        except subprocess.CalledProcessError as e:
            log.error("tmux command failed: %s", e)
        sd_notify("WATCHDOG=1")
        _shutdown.wait(TICK_SEC)

    graceful_shutdown()  # send /exit to claude, sleep, kill-session

The claude_alive check leans on three small tmux queries:

1
2
3
4
5
6
7
8
9
def has_session():
    return run("tmux", "has-session", "-t", SESSION).returncode == 0

def pane_command():
    out = run("tmux", "list-panes", "-t", SESSION, "-F", "#{pane_current_command}").stdout
    return out.strip().splitlines()[0] if out.strip() else None

def claude_alive():
    return pane_command() in {"claude", "node", "bun"}

Implementation Note: The foreground command can appear as claude, node, or bun depending on how Claude was packaged and which child process is currently active. Match all three; otherwise transient plugin activity will register as false-positive “Claude is dead” detections.

What the Supervisor Deliberately Does Not Do:

Mitigation for Custom Needs: If you need watchdog metrics, custom restart policies, or multi-bot support, write a slightly bigger supervisor. The shape stays the same. Resist the urge to use a process manager (supervisord, pm2) — they hide the model from you.


Init System Configuration: Three Directives Doing Real Work

For systemd-user (other init systems have direct equivalents):

1
2
3
4
5
6
[Service]
Type=notify           # supervisor sends READY=1 / WATCHDOG=1 via sd_notify
KillMode=process      # critical — see Pitfalls
Restart=always
RestartSec=10
WatchdogSec=120       # if WATCHDOG=1 stops, kill and restart
DirectivePurposeTuning Notes
Type=notifyAsync readiness signalTmux session creation takes a beat; this prevents systemd reporting active before bot is genuinely up
KillMode=processOnly kill named PIDWithout this, SIGKILL on supervisor reaps the entire control group, including tmux server
Restart=alwaysAuto-restart on any exitCombined with RestartSec=10, gives 10s recovery on supervisor crash
WatchdogSec=120Liveness deadlineShould be 3–4× supervisor tick interval (TICK_SEC=30) to absorb transient stalls
NotifyAccess=mainRestrict notify socketDefense-in-depth; only the main supervisor process can signal readiness

Best Practice:


Wiring Claude to the Chat Platform: The --channels Pattern

This is the piece where most of the work has been done by upstream. The exact magic:

1
claude --channels plugin:discord@claude-plugins-official

What --channels Actually Does

Without --channelsWith --channels
Plugin is a regular MCP tool sourcePlugin opens a WebSocket gateway connection
Claude can reply if instructed manuallyInbound messages auto-push as <channel> notifications
Plugin’s gateway never connectsPlugin actively listens, dispatches, receives
Bot is effectively deafBot is a real listener with full context flow

Setup Workflow (Discord example):

  1. Create bot in Discord Developer Portal

    • Enable Message Content Intent (privileged intent, but free for bots in <100 servers)
    • Generate bot token
    • Invite bot to a server you share with it
  2. Configure plugin

    1
    2
    3
    
    /plugin install discord@claude-plugins-official
    /reload-plugins
    /discord:configure $DISCORD_BOT_TOKEN
    
  3. Pair your account

    1
    
    /discord:access pair ABC123  # 6-char code from initial DM
    
  4. Lock down access

    1
    
    /discord:access policy allowlist  # post-pairing hardening
    

Privacy Trade-off: The Message Content Intent gives the bot access to all messages it can see. Only enable it on bots you control. For multi-tenant deployments, scope per-channel.

The same pattern applies to Telegram, Slack, IRC, Matrix — any platform with an MCP plugin supporting --channels. Swap the plugin name; the rest of the architecture works unchanged.


Permission Architecture: Trust by Design

This is the piece that distinguishes a real assistant from one running with the brakes off. The goal: non-destructive commands run silently; destructive ones ask, with prompts routing to chat.

Layer 1: Static Allow/Deny Lists

In ~/.claude/settings.local.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": [
      "Read", "Glob", "Grep",
      "Bash(ls *)", "Bash(cat *)", "Bash(grep *)",
      "Bash(git status *)", "Bash(git log *)", "Bash(git diff *)",
      "mcp__plugin_discord_discord__reply",
      "mcp__plugin_discord_discord__fetch_messages"
    ],
    "deny": [
      "Bash(rm -rf /*)", "Bash(dd if=* of=/dev/*)",
      "Bash(* > /etc/*)", "Bash(* > /boot/*)"
    ]
  }
}

Critical Configuration Points:

Layer 2: PreToolUse Hook for Compound Commands

Static Bash(...) patterns can’t classify compound forms. Bash(git log *) matches git log --oneline, but does not match cd /tmp && git log --oneline — because the literal command starts with cd.

This is where a PreToolUse hook earns its keep. Skeleton structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def is_piece_safe(piece):
    """Given a single command piece (no &&/||/|), return True if safe."""
    tokens = shlex.split(piece)
    if not tokens:
        return False
    verb = tokens[0]
    if verb == "cd":
        return True  # cd is harmless; surrounding piece checked separately
    if verb in HARD_DENY:
        return False
    if verb in SAFE_VERBS:
        return True
    if verb in SUBCOMMAND_SAFE:
        return tokens[1] in SUBCOMMAND_SAFE[verb] if tokens[1:] else False
    return False  # default: defer to Claude's normal ask flow

def main():
    payload = json.load(sys.stdin)
    cmd = payload["tool_input"]["command"]
    if any(s in cmd for s in ("$(", "`", "<(", ">(")):
        return  # subshells: defer
    if has_unsafe_redirect(cmd):
        return
    pieces = split_pipeline(cmd)
    if pieces and all(is_piece_safe(p) for p in pieces):
        emit_allow_decision()
    # otherwise: print nothing; Claude's normal ask flow takes over

Hook Design Principles

Verb Categorization:

CategoryExamplesHandling
Safels, cat, grep, find, awk, git status|log|diffAuto-approve
Subcommand-safegit, systemctl, tmux, npm, pipAuto-approve only with whitelisted first-args
Hard-denyrm, mv, dd, chmod, kill, make, bash, python3 -c, ssh, sudo, dockerNever auto-approve

The exact membership of each category is yours to decide. My version is conservative — python3 --version is allowed, python3 -c is not. Yours might be looser or tighter. The interesting design choice is the shape: silent for safe, ask for novel, deny for catastrophic.


Conversation Continuity Across Restarts

When the supervisor respawns Claude — for any reason — the new Claude starts fresh by default. Conversation memory: gone.

Claude already solves this; you just have to ask. The relevant flag: --resume <uuid>.

Implementation Pattern

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def latest_session_id(sessions_dir):
    candidates = []
    for name in os.listdir(sessions_dir):
        if name.endswith(".jsonl"):
            path = os.path.join(sessions_dir, name)
            candidates.append((os.path.getmtime(path), name[:-len(".jsonl")]))
    if not candidates:
        return None
    candidates.sort()
    return candidates[-1][1]

def claude_argv():
    sid = latest_session_id(SESSIONS_DIR)
    if sid:
        return [CLAUDE_BIN, "--resume", sid, "--channels", CHANNELS]
    return [CLAUDE_BIN, "--channels", CHANNELS]

Each Claude session is autosaved to ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl. The most recently modified JSONL is, by definition, the session you want to continue.

Recovery Flow on Graceful Restart:

  1. systemd sends SIGTERM to the supervisor
  2. Supervisor’s signal handler triggers stop_session()
  3. stop_session() sends /exit to Claude inside tmux
  4. Claude flushes its session JSONL to disk
  5. Supervisor exits cleanly
  6. systemd starts a new supervisor instance
  7. New supervisor scans ~/.claude/projects/<cwd>/, picks the latest JSONL
  8. Spawns Claude with --resume <that-uuid> — same conversation, new process

Verified Behavior: After sending several Discord messages and then systemctl restart on the supervisor, the resumed Claude correctly recalled prior context: which files were discussed, which permissions were granted, what the user asked for last.


Production Pitfalls and Mitigations

Four production-grade issues showed up between “it works on my machine” and “it survives SIGKILL”. All have one-line fixes once understood.

1. KillMode=control-group Erases Your Bot

Failure Mode:

Default systemd kills the entire control group when a unit stops. Tmux daemonizes its server into the supervisor’s cgroup the first time tmux new-session runs. SIGKILL on the supervisor reaps the tmux server with it — the session goes away, your conversation is lost, and “supervisor restart preserves bot state” becomes a lie.

Mitigation:

1
2
[Service]
KillMode=process

systemd now only kills the named process. The tmux server keeps running independently. The next supervisor instance finds the existing session and resumes monitoring. Journal logs:

1
2
Found left-over process 1277854 (tmux: server) in control group while
starting unit. Ignoring.

Caveat — read the systemd docs first. The official systemd.kill(5) manual states: “It is not recommended to set KillMode= to process or even none, as this allows processes to escape the service manager’s lifecycle and resource management.” That warning is appropriate for ordinary services; in our case, the entire point is for tmux to deliberately escape the supervisor’s lifecycle so the user-facing assistant outlives any supervisor crash. This is a knowing trade-off, not a misconfiguration. Document it in your unit file with a comment so future-you (or future-team) doesn’t undo it.


2. The Workspace Trust Dialog Blocks Startup

Failure Mode:

On first launch in any directory, Claude prompts: “Do you trust the files in this folder?” Reasonable for interactive sessions, catastrophic for headless supervisors — Claude blocks before the chat listener activates, so messages stack up unanswered.

Mitigation:

Persist the answer once via ~/.claude.json:

1
2
3
4
5
import json, pathlib
p = pathlib.Path("/home/jervis/.claude.json")
data = json.loads(p.read_text())
data["projects"]["/home/jervis"]["hasTrustDialogAccepted"] = True
p.write_text(json.dumps(data, indent=2) + "\n")

Set once, never re-prompts. If the file is rebuilt or the project key recreated under a different path, the prompt returns — the supervisor doesn’t re-set automatically.


3. Manual Tmux Input Disrupts Channel Mode

Failure Mode:

Inbound chat messages auto-push as channel notifications — as long as nobody types into the tmux pane. The moment you tmux attach and type anything (or tmux send-keys a query), Claude’s TUI switches to “manual input mode”: the next inbound message lands in the input box rather than auto-flowing as a turn. It sits there until someone presses Enter.

Mitigation:

This is upstream Claude TUI behavior; no supervisor-side fix exists. Operational workaround: if you tmux attach to peek, don’t type. Detach with Ctrl-b d. Auto-flow stays intact.


4. Some “Deny” Patterns Get Silently Dropped

Failure Mode:

The classic fork-bomb pattern Bash(:(){ :|:& };:*) looks like it belongs in your deny list. Claude’s settings parser rejects it as invalid syntax (the parens collide with the Bash(...) form) and silently skips the rule. Other deny patterns still apply, but you’ve added a phantom safeguard.

Mitigation:

Validate every deny pattern by inspecting Claude’s startup log for [settings] warnings. A pattern that fails parsing produces:

1
Settings Warning: Invalid permission rule: Bash(:(){ :|:& };:*)

Fix syntax or drop the rule entirely.


Adapting the Pattern to Your Needs

The supervisor I wrote is not the point. The shape is. Six places where your needs probably diverge:

1. Different Chat Platform

2. Different Supervisor Language

3. Different Init System

4. Different Permission Posture

5. Different Deployment Scope

6. Different Memory Expectations

Push-back: Don’t go back to --dangerously-skip-permissions because the hook felt like work. The hook is a one-time investment that pays off every single day the bot is running. Every prompt that lands in your DM is the cheapest possible audit log.


The Road Ahead: Toward Always-On Personal AI

The personal AI infrastructure landscape demands a paradigm shift toward:

1. Standardized Persistence Patterns

2. Permission System Maturity

3. Hardware-Native Always-On Agents

4. User Empowerment

As demonstrated by the Mac mini renaissance and the rapid adoption of personal compute, the substrate is here. The agent infrastructure is catching up. Through layered defenses combining static allowlists, hook-based dynamic classification, chat-mediated permission grants, and supervised lifecycle management, developers can reclaim hours from their day — one autonomous task at a time.


ComponentRecommended ChoiceKey Properties
Always-On HardwareApple Mac mini M4 / VPS (Hetzner CX22+)Low idle power, reliable uptime
Init Systemsystemd-user (Linux) / launchd (macOS)Watchdog support, auto-restart
Persistent Sessiontmux 3.3+Mature, ubiquitous, scriptable
AI RuntimeClaude Code 2.1+ with --channels modeNative chat-platform plugin support
Chat PlatformDiscord (free for personal use)Mature MCP plugin, mobile-first
Permission HookPython 3.10+ stdlibZero deps, easy to audit
Conversation StorageClaude’s built-in JSONL autosaveNo additional infra

Best Practices for Builders

For Solo Developers

  1. Start with Pattern 3, then add the supervisor

    • Run claude --channels interactively first; verify the chat round-trip works end-to-end
    • Only then wrap with the supervisor — debugging two layers at once is painful
  2. Build the hook incrementally

    • Start with an empty SAFE_VERBS. Every command claude tries that you’d want auto-approved becomes a list addition.
    • Within a week, you’ll have a stable allowlist matching your actual workflow.
  3. Use tmux attach early and often

    • Live observation of your bot working is the most useful debugging tool you have
    • Detach without typing to preserve channel mode

For Team Deployments

  1. Multi-User Access Policy

    • Use the chat plugin’s access.json with explicit allowFrom lists
    • Implement per-channel scoping with requireMention to limit blast radius
  2. Audit Logging

    • Hook permission decisions to a logging endpoint
    • Surface destructive op approvals in a separate review channel
  3. Operational Playbooks

    • Document the tmux attach workflow for on-call engineers
    • Define escalation criteria for “bot has been wrong about X for Y hours”

Conclusion: Reclaiming Time from the Always-On Box

The lesson I keep relearning is that the platform is usually further along than I think. I started by writing a polling daemon because I was thinking “Claude is a CLI; I’ll wrap it.” The right question was: “What does Claude Code already support that I should be using?”

The answer was --channels, the official chat plugins, the permission-reply protocol, --resume, and the hook system. Five pieces of infrastructure that were already there, doing the work I was about to do badly in 300 lines of polling code.

What’s left is a thin layer of Unix glue: keep tmux up, keep Claude inside it, hand off conversation IDs across restarts. The interesting code lives upstream, where someone else maintains it. The “bot” is mostly a watchdog plus a configuration choice.

The buddy’s been running for a while now. It drafts things while I’m not at the desk. Watches builds. Pings me when something genuinely needs my eyes. I’ve spent fewer evenings staring at my phone wondering if a deploy went through. My weekends have a few more hours in them than they used to.

The always-on box on my shelf — the one I bought to host whatever — finally feels like it’s worth the electricity.

If you have an always-on machine, you have a buddy waiting to be activated. The pattern is small. The trade-offs are knowable. The time it gives back is real.

Build it. Tune it. Lean on it.

Stay autonomous, stay supervised.


References

All references below were verified live as of May 2026. URLs are the canonical, primary sources for the claims and code referenced in this article.

Claude Code & The Plugin Ecosystem

Model Context Protocol (MCP)

Always-On Personal Compute

AI Agents Market Forecasts

Persistent Session Management

Systemd Configuration

Chat Platform Documentation

Source Code (Reference Implementation)


A note on statistics in this article. All cited market figures and dates come from the linked sources. Personal observations (latency anecdotes, deployment specifics, “the buddy’s been running…”) are explicitly framed as such and reflect a single deployment — your mileage will vary.


About the Author

Syed Hasibur Rahman is a physics-turned-AI engineer specializing in geospatial AI systems and ethical technology development. He builds and breaks small tools to keep them honest, and runs his own digital buddy on a VPS in a datacenter he’s never visited.


Originally published on May 10, 2026.