DEV Community

yureki_lab
yureki_lab

Posted on

How I Built a Remote Control Dashboard for My Autonomous Coding Agent

TL;DR

I run a fully autonomous implementation system 24/7 on a Mac mini. For months, the only way to intervene was to SSH in and kill processes by hand. So I built a small remote control dashboard I can open on my phone to pause, steer, approve, and inspect the agent from anywhere. This post walks through the design (a file-based command queue, an approval gate, and a heartbeat), the code that matters, and five lessons from six months of running it.

The Problem

Here's the setup: an orchestrator module spawns parallel implementation agents built on Claude Code (v2.1.x at the time of writing), each working on a task from a queue. It plans, writes code, runs tests, commits, and moves on. It runs while I sleep. It runs while I'm at the gym. It runs while I'm on a train with no laptop.

That last part was the issue. 🚨

Three things kept happening:

  1. The agent would go down a rabbit hole. A "rename this config key" task would turn into a 40-file refactor because the agent decided the old name was confusing everywhere. I'd find out 6 hours later.
  2. It would hit a decision it shouldn't make alone. Delete a migration? Push to a shared branch? Rotate a credential? The agent was told to stop and wait for a human on those. But "wait for a human" meant "wait until I'm at my desk".
  3. I had no idea what it was doing right now. Logs were on the box. Reading them meant a terminal, a VPN, and tail -f. Not a thing you do from a phone at dinner.

My first instinct was "just SSH from the phone". I tried it for two weeks. Typing kill -9 on a 6-inch screen at 2am while half-asleep is a great way to kill the wrong process. I did that. Twice.

What I actually needed was a remote control, not a remote shell. A handful of big, safe buttons and a clear status readout.

How I Solved It

Design constraints

I set three rules before writing any code:

  • The agent must not depend on the dashboard. If the dashboard is down, the agent keeps working. If the agent is down, the dashboard says so. No shared process, no shared database.
  • Every command must be idempotent and safe to replay. Mobile networks retry. Tapping "pause" twice must not do anything weird.
  • Read is cheap, write is gated. Anyone with the link can see status (it's behind auth anyway). Writing a command requires a second factor.

Architecture

The whole thing is three parts talking through the filesystem:

flowchart LR
    Phone[Phone browser] -->|HTTPS| Dash[Dashboard server\nFastAPI]
    Dash -->|writes JSON| Queue[(commands/ dir)]
    Dash -->|reads| Status[(status.json + logs)]
    Agent[Autonomous agent loop] -->|polls every 5s| Queue
    Agent -->|writes every 30s| Status

Yes, a directory of JSON files as a queue. No Redis, no Postgres, no message broker. I'll defend that choice in the lessons section.

The command queue

Each command is a single file. The filename is a UUID; the content is a tiny JSON document. The agent's main loop polls the directory between task steps:

# agent side: runs between every tool call / task step
import json, os, time
from pathlib import Path

COMMANDS = Path("~/agent/commands").expanduser()
PROCESSED = COMMANDS / "processed"

def drain_commands(state):
    for f in sorted(COMMANDS.glob("*.json")):
        try:
            cmd = json.loads(f.read_text())
        except json.JSONDecodeError:
            f.rename(PROCESSED / f"{f.name}.corrupt")
            continue

        kind = cmd.get("kind")
        if kind == "pause":
            state.paused = True
        elif kind == "resume":
            state.paused = False
        elif kind == "abort_task":
            state.abort_current = True
        elif kind == "steer":
            # appended to the next prompt as a user instruction
            state.pending_notes.append(cmd["text"])
        elif kind == "approve":
            state.approvals.add(cmd["request_id"])
        elif kind == "deny":
            state.denials.add(cmd["request_id"])

        f.rename(PROCESSED / f.name)
Enter fullscreen mode Exit fullscreen mode

The steer command is the one I use most. It doesn't interrupt anything. It just says: "before your next step, read this note from me". Things like "Don't touch the billing module, I'm working on it locally" or "The flaky test is known, skip it and keep going". The orchestrator injects it into the next prompt as a high-priority user message.

The approval gate

This is the part that changed how I sleep. 😴

The agent has a list of actions it is not allowed to do without a human. Deleting files outside the repo, force-pushing, running migrations against anything but a local database, touching secrets. When it hits one, it doesn't fail and it doesn't guess. It writes an approval request and blocks on that request ID:

# agent side
def request_approval(action: str, detail: str, timeout_s: int = 6 * 3600) -> bool:
    req_id = uuid.uuid4().hex
    (REQUESTS / f"{req_id}.json").write_text(json.dumps({
        "id": req_id,
        "action": action,
        "detail": detail,
        "created": time.time(),
    }))
    notify_phone(f"Approval needed: {action}")   # push notification
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        drain_commands(state)
        if req_id in state.approvals:
            return True
        if req_id in state.denials:
            return False
        time.sleep(5)
    return False   # timeout = deny, always
Enter fullscreen mode Exit fullscreen mode

Timeout equals deny. Always. I went back and forth on this. An agent that treats silence as consent is an agent that will eventually do something irreversible at 3am because I was asleep. Silence means "not now", and the task gets parked, not dropped.

On the dashboard, an approval request renders as a card with the action, the detail (usually a diff or a command), and two big buttons. Green and red. That's it.

The status heartbeat

The agent writes a status.json every 30 seconds and after every task boundary:

{
  "ts": 1789740000,
  "state": "running",
  "current_task": "Add retry to webhook delivery",
  "step": 14,
  "tokens_today": 812000,
  "last_commit": "a1f3c9e",
  "pending_approvals": 1,
  "agents_active": 3
}
Enter fullscreen mode Exit fullscreen mode

The dashboard reads it and does one thing I'd underrate if I hadn't lived without it: it shows how stale the heartbeat is. If ts is more than 90 seconds old, the header goes amber. More than 5 minutes, red. That single indicator caught two hung processes and one out-of-disk incident before anything else did.

The dashboard itself

Roughly 300 lines of FastAPI (Python 3.13) plus one HTML template with vanilla JavaScript. No framework. It fits on a phone because I designed it on a phone first:

  • Top: state badge, heartbeat age, current task.
  • Middle: pending approval cards (if any).
  • Bottom: four buttons. Pause, Resume, Abort current, Steer (opens a text box).
  • Last 50 log lines behind a collapsible section.

Writes go through a one-time code from an authenticator app. Reads are just behind the reverse proxy's basic auth. The server itself runs as a separate background service so it survives agent restarts, and it never imports anything from the agent codebase.

@app.post("/cmd/{kind}")
def post_command(kind: str, body: CommandIn, totp: str = Header(...)):
    if kind not in ALLOWED_KINDS:
        raise HTTPException(400)
    if not verify_totp(totp):
        raise HTTPException(403)
    payload = {"kind": kind, "created": time.time(), **body.model_dump()}
    tmp = COMMANDS / f".{uuid.uuid4().hex}.tmp"
    tmp.write_text(json.dumps(payload))
    tmp.rename(COMMANDS / f"{uuid.uuid4().hex}.json")   # atomic
    return {"ok": True}
Enter fullscreen mode Exit fullscreen mode

Write to a temp file, then rename. The agent never sees a half-written command.

Lessons Learned

1. A directory of JSON files beats a queue you have to keep alive

I got roasted for this in a Discord. Fine. Here's the thing: the queue has zero dependencies, survives reboots, is inspectable with ls, and debuggable with cat. In six months it has never been the failing component. Every "real" queue I've run has needed babysitting at some point. For a single-machine, single-consumer, low-volume control channel, files win. πŸ’‘

2. "Steer" is worth more than "stop"

I built pause and abort first because they felt like the safety features. I use steer ten times more often. Most interventions aren't "stop everything", they're "you're missing context, here it is, keep going". Giving the agent a way to receive mid-task notes without losing its place turned a lot of would-be aborts into small corrections.

3. Silence must mean no

Any autonomous system with a human-in-the-loop gate will eventually run into the human being unavailable. Decide up front what happens. The answer that lets you sleep is "park the task and move on". The answer that ends your weekend is "assume yes after N minutes".

4. Heartbeat age is the most important pixel on the screen

Not the log. Not the task name. The number of seconds since the agent last said "I'm alive". Everything else can be wrong or stale; that number tells you whether to trust the rest of the screen.

5. Design the control surface for your dumbest future self

The person using this dashboard is me at 2am, on a phone, with one eye open. Big buttons. Confirmation on anything destructive. No text input required for the common path. The agent is sophisticated; the remote control should be boring. ⚠️

What's Next

Two things I'm working on:

  • Approval bundling. Right now each gated action is its own request. When the agent is migrating something, I get five approvals in a row. I want it to batch related requests into one card with one decision.
  • Read-only sharing. A teammate wants to watch the agent work on a shared repo without being able to steer it. That means separating the read and write auth properly instead of leaning on the reverse proxy.

Longer term, I want the dashboard to show why the agent is doing what it's doing, not just what. A short "current reasoning" field in the heartbeat, written by the agent in one sentence, would go a long way.

Wrap-up

If you're running any kind of long-lived AI coding agent and your intervention story is "SSH in and kill it", build the remote control. It's a weekend of work. The command queue is 40 lines, the approval gate is 30, the dashboard is an afternoon. You'll never go back to tail -f on a phone.

If this was useful, follow me here on Dev.to πŸš€ β€” I'm writing up the rest of this autonomous system piece by piece: the orchestrator, the self-healing agent, and the observability layer. And if you've built something similar, tell me in the comments what your "steer" equivalent looks like. I want to steal your ideas.

Top comments (0)