I love Claude Code, but I want to be able to fire it off on my Mac Mini no matter where I am. How should I do this? In practice, I’ve ended up with two options.
Note: I may refer to Claude Code as “Claude” a few times in this writing. Each time I am talking about the Claude Code command line tool, not the desktop or web application.
Under the hood, both approaches lean on the Rails Solid Stack: Rails, Solid Queue for background jobs, and Solid Cable for real‑time updates, all backed by SQLite. Solid Queue and Solid Cable shipped with Rails 7.1, and together they make it trivial to build a full “app + jobs + real‑time” setup without pulling in Redis or Sidekiq. This has become the default stack in Rails 8 and has greatly simplified the generation of very sophisticated applications.
There are two basic patterns:
- Run everything locally and connect to your Mac from the internet to fire off Claude.
- Run the UI on the internet and have that server talk back to your Mac, which actually runs Claude.
In practice, these turn into two distinct architectures:
Pattern 1 – Tunnel everything:
Run the entire Rails app, job queue, and Claude CLI on the Mac Mini, and then punch a secure hole into it with ngrok so you can hit it from anywhere.
Pattern 2 – Remote UI + local executor:
Run the Rails UI in “real” production (EC2 + Docker), and treat your Mac Mini as a dedicated Claude worker box that polls the server for work.
There are a couple of subtle differences there. In the end, both patterns still run Claude on your machine, but there are some complicated pieces you need to set up for each option and some security aspects to think about as well.
Pattern 1: Tunnel Everything to Your Mac
Step one for this pattern is to make sure Claude is running in some sort of background process so that the application tying everything together can be worked on by Claude and updated live. I love using Rails for this and setting up the Solid Stack for all of my background processing. It’s easy, and SQLite is the only real overhead (other than memory).
Next, you create a tunnel from the internet directly into your computer. There are a number of insecure ways to do that, but one secure way I like is using ngrok. They’ve been providing remote access to local systems for over a decade and they provide a secure layer with a number of available authentication gates you can use out of the box.
When I go the “everything local” route, the setup on my Mac Mini looks like this:
Internet → ngrok → localhost:3042 → Rails app → Solid Queue → Claude CLI
There’s no remote server here. The “production” URL that I hit from my laptop or phone is just an ngrok tunnel into a Rails app that’s bound to localhost:3042 on the Mac Mini (notice the use of a custom port so that other rails applications can also run locally.)
I wire it up with a Procfile.dev so one command (bin/dev) starts everything:
# Procfile.dev
web: bin/rails server -b 0.0.0.0 -p 3042
jobs: bin/jobs # Solid Queue worker (separate process)
css: bin/rails tailwindcss:watch
tunnel: bin/tunnel # ngrok
That jobs process is important: Claude sessions are long‑running, and I don’t want a code reload in the web process to kill an active stream. So Solid Queue runs in its own process and talks to the web process over the database.
The tunnel itself is just a small script:
#!/usr/bin/env bash
set -e
echo "Starting ngrok tunnel..."
# Clean up ngrok on exit
cleanup() { pkill -f ngrok 2>/dev/null || true; }
trap cleanup EXIT INT TERM
# Wait for Rails server on 3042
for i in {1..30}; do
if lsof -i:3042 > /dev/null 2>&1; then
break
fi
sleep 1
done
# Start ngrok, merging system config (auth token) with project config
NGROK_DEFAULT_CONFIG="$HOME/Library/Application Support/ngrok/ngrok.yml"
if [ -f "ngrok.yml" ] && [ -f "$NGROK_DEFAULT_CONFIG" ]; then
ngrok start nous --config "$NGROK_DEFAULT_CONFIG" --config ngrok.yml
elif [ -f "ngrok.yml" ]; then
ngrok start nous --config ngrok.yml
else
ngrok http 3042 --host-header="localhost:3042"
fi
From my point of view as a user, the flow is:
- I open the ngrok URL on whatever device I’m on.
- I type a message in the browser.
- Rails writes a
Messagerow to SQLite and enqueues a job. - The Solid Queue worker picks up the job, spawns
claudelocally, and streams the JSON events line by line back into the same SQLite DB. - ActionCable (via
solid_cable) watches that DB and pushes each newMessageover WebSockets to the browser, so I see Claude using tools and responding in real time.
The core of that bridge is just: spawn Claude with --output-format stream-json and turn every line into a Message:
cmd = [
"claude",
"--output-format", "stream-json",
"--verbose",
"--allowed-tools", allowed_tools,
("--resume" if conversation.session_id.present?),
"-p", prompt
].compact
Open3.popen3(clean_env, *cmd, chdir: working_dir, pgroup: true) do |stdin, stdout, stderr, wait_thread|
conversation.update!(pid: wait_thread.pid)
stdin.close
stdout.each_line do |line|
next if line.strip.blank?
event = JSON.parse(line.strip)
handle_event(event) # write Messages into SQLite, broadcast via ActionCable
end
end
Because the web process and the job worker share the same databases (one for app data, one for jobs, one for ActionCable), there’s no HTTP in the inner loop at all—messages are just DB rows and WebSocket broadcasts. The only internet
part is ngrok.
Pattern 2: Remote UI, Local Executor
For the second option, you create some sort of connection between your local system and the remote server where you’re typing your messages. This can be done with a daemon process that runs locally and polls the server for new messages, or it can be done by a push process where you push messages from the server to a local listener that is ready to take those messages and dispatch agents. Either way, there must be a mechanism to pull the data from the web application into the local environment to create the Claude code session.
On the “remote UI + local executor” side, I split things cleanly in two:
Internet → EC2 (Docker) → Rails API + SQLite
↕ HTTP (token auth)
Mac Mini → Orchestrator daemon → Claude CLI
- The EC2 box runs a Rails app that is accessible from the internet. It owns the UI, auth, and database.
- The Mac Mini is where Claude code actually runs. It has my repos checked out and my Claude code authenticated.
- The glue between them is a tiny HTTP API, plus a long‑running orchestrator daemon on the Mac that polls for work.
The orchestrator is a plain Ruby script I run under launchd so it survives reboots. Its main loop looks roughly like this:
POLL_INTERVAL = 60
MAX_CONCURRENT_AGENTS = 3
api = TendApiClient.new
running = true
trap("INT") { running = false }
trap("TERM") { running = false }
while running
begin
queue = api.queue # GET /api/queue
dispatch_ready_tasks(queue) # spawn agent-workers
dispatch_pending_chats # spawn chat-workers
check_stale_runs # kill anything without heartbeats
rescue TendApiClient::ApiError => e
log "API error: #{e.message}"
rescue => e
log "Error: #{e.message}"
end
sleep POLL_INTERVAL
end
When it sees a task it can run, it spawns a detached worker process:
pid = spawn(
ENV.to_h,
"#{TEND_PATH}/bin/agent-worker", task_id.to_s,
chdir: TEND_PATH,
pgroup: true,
out: "#{LOG_DIR}/agent-worker-#{task_id}.log",
err: "#{LOG_DIR}/agent-worker-${task_id}.log"
)
Process.detach(pid)
Each worker is responsible for a single Claude code session. Inside, it does the same basic thing as the tunnel setup: spawn claude with --output-format stream-json, then stream events back—except now that data has to go over HTTP to the EC2 app.
The shared stream handler looks something like this:
Open3.popen3(clean_env, *cmd, chdir: working_dir, pgroup: true) do |stdin, stdout, stderr, wait_thread|
api.update_conversation(conversation_id, { pid: wait_thread.pid })
stdin.close
buffer = []
last_flush = Time.now
stdout.each_line do |line|
next if line.strip.empty?
event = JSON.parse(line.strip)
# Capture session_id once (for --resume)
if event["type"] == "system" && event["subtype"] == "init" && event["session_id"]
api.update_conversation(conversation_id, { session_id: event["session_id"] })
end
# Turn Claude event into one or more Messages
buffer.concat(event_to_message(event))
# Flush in batches to avoid hammering the API
if buffer.size >= 30 || Time.now - last_flush > 10
api.add_messages_batch(conversation_id, buffer) # POST /api/conversations/:id/messages/batch
buffer.clear
last_flush = Time.now
end
end
# Final flush
api.add_messages_batch(conversation_id, buffer) if buffer.any?
end
On the EC2 side, everything is just regular Rails:
ConversationandMessagemodels in SQLite.- Turbo Streams to push messages to the browser in real time.
- A small set of authenticated JSON endpoints the Mac Mini talks to:
GET /api/queue # what should I work on?
GET /api/conversations/pending_chats # any user chats waiting?
POST /api/conversations/:id/messages/batch
PATCH /api/agent_runs/:id/heartbeat
The experience from my phone looks the same as the tunnel setup: I open a URL, type into a text box, and watch Claude stream back. The big difference is where things live:
- The URL and UI live in “normal” production on EC2.
- All Claude execution is anchored to my Mac Mini.
- If the Mini goes offline, the app still loads; it just doesn’t start new runs until the daemon comes back and resumes polling.
Choosing Between the Two
Creating the session and sending the message is only half the battle. After that, you need to capture the streaming JSON that’s returned and provide it back to the user (yourself) in a useful way.
So which way do I actually use?
Both!
If I’m doing work stuff and I want the fastest possible loop, I use the ngrok tunnel pattern. I run bin/dev on my laptop, tap the ngrok link on my phone, and I’m chatting with Claude against my local repos in seconds. But typically I’m at my house and I can just use local network and not even have to go through ngrok for this setup. I just hit my local network with the correct port.
If I want something that feels like a real multi‑user app with a stable URL, deploys via Docker, and keeps working even when the Mini is sleeping, I reach for the EC2 + polling daemon pattern. I use this for all my personal projects. It’s more moving parts (daemon, workers, heartbeats, API), but it behaves like a traditional web app with a remote “Claude appliance” behind it. I can even set up multiple users and share projects with my friends and family so they can collaborate using my single Claude subscription. (I suppose either setup could be shared, but I use ngrok login as the gate on my local application architecture so the gate would need to move to the application.)
Both patterns are doing the same basic trick: spawn the Claude CLI on the machine that has the code, read the --output-format stream-json stream, and move those events into a Rails-friendly shape (Conversation + Message rows). Everything else—ngrok vs EC2, ActionCable vs HTTP batch POSTs—is just different ways of getting that stream from my Mac Mini back into a browser.
The future I always wanted
I have tried so many times to setup movie coding environments, and now that coding has moved to orchestration and chat I can finally achieve it in a seamless way. I now have PWAs set up on my phone that can directly alter multiple projects with only text prompts!

My two PWAs installed, complete with their own self-made favicons!
The future is here!