Skip to content
Guide title card reading "Claude Code from your phone" over an infinity pool at golden hour, loungers and umbrellas along both decks and jungle running to the horizon

← Free GuidesPart 5 of 5, the beach chair series →

Claude Code Was Chained To My Mac. Now It Deploys Work From My Phone

Part of Building with Claude Code and AI operating systems

Thursday morning, Strands Beach in Dana Point. I'm down there most Thursdays for a beach workout.

Sitting with a coffee afterward, I cracked something I'd been circling for weeks. My prospect definition was too wide, and I could finally name the exact signals that separate a company that looks like a buyer from one that actually is. That's the kind of idea that decides whether outreach lands or gets ignored.

The fix was a change to how my machine pulls and enriches prospect data. The catch is that the next batch builds itself against whichever definition is live when it runs. So a great idea on Thursday morning is worth nothing if it ships on Friday.

Before this, that's a note in my phone and a drive back to the office. Make the change, ship it, watch the next run pick it up. I've done that a hundred times.

Instead I described the change to my Claude Code session from my phone. It made the edit and opened a pull request. I read it, merged it, told my server to deploy, and watched the server confirm it was running the new code. The next batch used the new definition.

Then I stayed another hour.

Let me kill the obvious read

This is not a post about working from the beach. I work long hours, most of them at a desk, because that's where the hard work happens. The hour on the sand isn't the win.

The win is that the hour was mine to spend. For years the answer to "does this have to wait?" was decided by where my computer was, not by how important the thing was.

You already know you're chained to your computer. Everybody does. The part nobody argues with is the next one: that the chain is permanent, the unavoidable cost of running something real.

It isn't. It's a design decision, usually made by somebody who is not you, and it can be undone. This is how I undid mine.

Where this fits

Most of what my company does, it does without me. How the agents are organized, who reports to whom, how work moves through GitHub, how a shared wall keeps twenty of them out of each other's way. That's all written down in the other guides in this series. Read those and you can get a system running that ships its own code.

This one is the last mile. The moment something has to go live, and you're not at your desk, and you don't want the answer to be "drive back."

And this piece is the story, not the manual. If you want to build it rather than read about it, I wrote the whole thing down separately: Deploy From Your Phone: The Build (the prerequisites, the endpoint code itself, why each safety property is there, and how to prove it landed).

Why that used to be impossible

Two separate walls, and I'd been living with both.

The first: a desktop Claude Code session is pointed at a folder. That's not a detail, it's the whole product. It reads your files, runs your tests, watches your logs, because it's standing inside your repository on your computer. And a session standing in a folder on a Mac cannot leave that Mac. On earlier Thursdays I could see from the beach that a run had fired, and read the line proving it. I couldn't open the conversation, because from the beach there was no conversation to open. No update fixes that, because nothing is broken. It's just what the words mean.

The second wall was worse, and older. Shipping anything to production meant an SSH from that same Mac, because that's where my server's key lived. So the system could do the work, review it, and approve it. And then stop, and wait for me to physically walk to a desk.

"Ship it" quietly meant "be at that desk."

mermaid
flowchart LR
  phone["my phone"] -. no way in .-> session["Claude Code session<br/>lives in a folder"]
  session --> mac["the Mac in my office<br/>holds the only server key"]
  mac -->|SSH| box["the server that<br/>runs my company"]

Nobody works here but me. No employees, no contractors. The work gets done by agents I built, running on a server I own, and the whole thesis of a one-person company is that the business keeps moving when the person stops. I'd built most of that and then discovered it still required me to be in one particular room.

In a one-person company, you are the only ceiling. Every system you build to take work off you tends to hand something back: a step only you can approve, a tool only you can reach, a room only you can be in.

The two fixes I didn't take

Wait for the app to change. Apps change, apps change back, and you spend the interval organizing your life around somebody else's release notes. I've watched businesses do this with their CRM, their booking tool, their ad platform. They all call it temporary.

Remote into the Mac from my phone. This would have worked. It would also have meant the way I run my company is a remote-desktop app, on top of a desktop app, on top of a laptop that has to stay awake in an empty office and never crash. Three things that can each change on a Tuesday.

Both are the same move: rent the freedom back. When there's one of you, that's a bad trade, because nobody covers for you when the rented thing breaks.

My company got a front door of its own

Here's the reframe, and it's the part worth stealing whether or not you write code.

I'd been treating my Mac as the way in. It was never the way in. It was a device that happened to hold a key. My company doesn't live on the Mac. It lives on a twelve-dollar-a-month DigitalOcean server that I own, and a server can have a front door of its own.

Three pieces. None of them depend on an app behaving.

One. The session moved to the cloud. Claude Code also runs as a cloud session, with no folder and no machine. That version shows up on my phone, in the desktop sidebar, and in a browser tab: one continuous conversation across all three, not three copies syncing. This half of the answer had been sitting there the whole time. I was using the version that's better at everything except leaving.

Same session, two devices. The sidebar is the whole company: one named session per agent.

One warning, because it was the original failure: don't create that session as a scheduled routine. Routine-spawned sessions are a different class and may never appear in your session list at all. Open a new session and point it at your repo, by hand, once.

Two. The box updates itself. My server already held a read-only key to my own code, so it could always pull its own updates. It just had nothing to tell it when. Now it has exactly one locked endpoint. Knock with the right credential and the box fetches the approved code, snapshots its own database, installs, migrates, and restarts its services. No Mac in the path. No server key on the device in my hand.

Three. Proof is a measurement. There's a second address, public and needing no password, that answers one question: what code are you running right now. After a deploy you read it until it says the version you approved. Not a summary, not a promise. The running process, asked directly.

mermaid
flowchart LR
  phone["my phone"] --> cloud["Claude Code cloud session<br/>no folder, no machine"]
  cloud -->|approve + merge| gh["GitHub"]
  cloud -->|"knock: deploy"| box["the server<br/>pulls its own code"]
  gh -. read-only key .-> box
  box -->|"what am I running?"| phone

Review from anywhere, approve from anywhere, ship from anywhere, and verify by reading the machine instead of believing the messenger.

A door that ships code deserves scrutiny

Anything that can change production from the open internet should make you nervous. Here's how nervous I was.

The door has its own key and that key does one job. It can ship the approved main line and nothing else. It isn't my dashboard login and it isn't my database password. If it leaked, the worst anyone could do is redeploy code that's already public. It rotates in one command and breaks nothing.

The caller chooses nothing. The request body is thrown away unread. No branch, no path, no options. An endpoint that ships whatever the caller names is an endpoint that runs a stranger's code as root on your server.

Missing means no. If the credential is absent entirely, nobody is authorized rather than everybody. Security mistakes have a direction, and that's the direction this one has to fail in.

The box keeps its own rails. If files on the server were edited by hand, the deploy fails loudly and changes nothing rather than quietly throwing that work away. The database is snapshotted before any migration runs. Two deploys can't overlap.

The detail I like most is smaller than any of those. The deploy restarts the very process that's answering the request, so the endpoint can't honestly say "done." It hands the job off and answers "started," and then you go read the other address to find out how it went.

The proof

I don't publish things I haven't run. Measured on the live box:

CallResult
Deploy with no credential401, refused
Deploy with a wrong credential401, refused
Deploy with the real credential202, started
The background job that does the workfinished, clean exit
All services after the restartrunning
The public health addressreporting the exact commit I'd approved

My Mac wasn't involved. No server key went near my phone. The deploy key never appeared in the conversation at all, because the box reads its own.

Build it yourself

Everything above is in production on my box right now, and none of it is complicated. It is one route, one narrow key, one update script and one public endpoint that answers a single question.

I wrote the build up in full, with the code: Deploy From Your Phone: The Build. It covers what you need before you start, the endpoint and each of its rails, where every secret lives, the checks that tell you it actually works, and the two DNS traps that look like server faults and are not.

The part that's actually about your business

I've been describing a server. You may not have one and may never want one. The three principles underneath don't care, and they matter most to the smallest companies, because a small company has nobody to cover for it.

1. Own the door, not just the room

Every business runs on borrowed doors. Your CRM's mobile app. Your scheduler's dashboard. The platform your customers message you on. Each one is somebody else's entrance to your own company, and when it changes, your ability to operate changes with it. On their schedule, not yours.

The question isn't "is this tool any good." It's: if this vendor changed tomorrow, could I still reach my own business? Where the answer is no, that's a dependency, and the minimum is to know you have it before it moves.

2. Proof is a reading, not a report

The reason I let an agent ship code to my production server while I'm sitting on sand isn't that it tells me it worked. It's that I can ask the running system what version it's running, and that answer isn't a claim anybody made.

When you're one person you can't supervise anything. You can only verify it. "It's handled" is a report. "Here's the thing itself, go look" is a reading. Every process in your company you can't check by reading something real is a process running on faith. Some have earned it. Name the ones that haven't.

3. Write down what still needs you

I didn't take myself out of the loop. I wrote the loop down.

My system reviews, merges and ships approved work on its own, all day, without asking me. Four things stop it cold: anything that spends money, anything that changes how fast we email people, anything that publishes or sends in my name, and anything that deletes.

mermaid
flowchart TB
  work["work finishes overnight"] --> gate{"does it touch money,<br/>send rate, publishing,<br/>or deletion?"}
  gate -->|no| ship["reviewed, merged, shipped<br/>without me"]
  gate -->|yes| me["waits for me"]

That list lives in a file inside the system it governs, so the same changes that update the business update the instructions. Not in a chat message and not in my head. Most companies keep their real operating rules in a document nobody opens plus a founder's memory, which is the same problem with better furniture.

That list is also the honest definition of what I'm building. A one-person company isn't one where nothing needs you. It's one where the things that need you are written down and short. Everything else moves without you.

What it can't do

The cloud session has no shell on my box. No logs, no database, no files. It knows exactly what the health address tells it and nothing more, and its standing order is to say so instead of guessing. It's specifically not allowed to tell me something is live because it called the endpoint. It has to go read.

That constraint is a feature. An assistant that reports success it didn't verify is worse than one that reports nothing, because you'll act on it.

Where this leaves me

Most days I'm still at the desk, by choice, working long hours on things that need long hours. But the change I made on that Thursday shipped from a phone, on hardware I own, through a door I built, with one narrow key and a written list of the decisions that are still mine.

I'm not chained to a computer anymore. I just use one, most of the time, because I want to.

That's the whole difference: a business that needs you in a room, versus a business that needs you on a question.

How to deploy from your phone: the build

By the end you can review work, merge it, ship it to your production server, and prove it landed, from a phone, with no laptop involved and no server key on the device in your hand.

Every command here ran against a live box. Every failure I describe actually happened to me.

Before you start

You need three things. If you have all three this takes an afternoon.

  • A server you control. Mine is a twelve-dollar-a-month DigitalOcean box. Anything you can

SSH into and run a service on works.

  • Your code in a repo the server can read. The server needs a read-only deploy key so it can

pull its own updates. Most setups already have one.

  • A web service already running on that server. You are adding one route to something that

exists, not standing up a new app.

If your deploys today are "SSH in and pull," you have everything you need.

The two walls, and why neither is your fault

A desktop session cannot leave the machine it is pointed at. A desktop Claude Code session is bound to a folder on your computer. From a phone you can see that a scheduled run fired and read the line proving it, but you cannot open the conversation, because out there no conversation exists. No amount of restarting fixes this. Nothing is broken. It is what "runs in this folder on this machine" means, and hours disappear into that misunderstanding.

The deploy key lives wherever you deploy from. If shipping means SSHing from your office, the key is in your office, and "ship it" quietly means "be at that desk."

Both walls come down the same way: stop treating your laptop as the way in, and give the server a front door of its own.

The shape of it

mermaid
flowchart LR
  phone["your phone<br/>cloud session"] -->|review + merge| gh["GitHub main"]
  phone -->|"POST /deploy"| box["your server<br/>pulls its own code"]
  gh -. read-only deploy key .-> box
  box -->|"GET /health<br/>what am I running?"| phone

Three parts, none of which depend on an app behaving:

  1. A cloud session, not a desktop one. No folder, no machine, so it opens on your phone, your

desktop and a browser as one continuous conversation.

  1. The server updates itself. It already has read access to your code. It just needs

something to tell it when.

  1. Verification is a measurement. A public endpoint reports the commit actually running. You

read it until it matches what you merged.

---

Step 1: Create the session on your phone

Open the app, start a new session, point it at your repo.

Do not create it as a scheduled routine. Routine-spawned sessions are a different class and may never appear in your session list at all. That was my original failure and it is not configurable from your side. Start a normal session, by hand, once.

Step 2: Put the charter in the repo, not in the chat

Write the session's standing instructions into a file in your repo and have it read that file on startup.

A charter pasted into a chat box is accurate for about a day. A charter in the repo is updated by the same changes that update the system. Keep only durable things in it: who does what, the standing orders, the merge rule, how to deploy, and the mistakes that have actually bitten you. For anything live, point at the source instead. A document that repeats live state starts lying the moment somebody ships.

Then your paste is one line:

You are my remote command deck. Read docs/STANDING_BRIEF.md first. It is your charter. Then report what is open and what needs my decision.

Step 3: Mint a key that does exactly one job

The obvious move is to hand the session the API token your server already has. Don't.

When I searched for mine, that same string was also my dashboard login and the seed signing the unsubscribe links in my email. Three doors, one key.

The deploy door gets its own key, and that key can do one thing: ship the main branch. If it leaks, the worst anyone can do is redeploy code that is already public. It rotates in one command and breaks nothing else.

bash
ssh root@YOUR_SERVER 'cd /opt/app \
  && T=$(python3 -c "import secrets;print(secrets.token_urlsafe(32))") \
  && sed -i "/^DEPLOY_TOKEN=/d" .env \
  && printf "DEPLOY_TOKEN=%s\n" "$T" >> .env \
  && systemctl restart your-web-service \
  && echo "COPY THIS INTO THE SESSION ENV:" && echo "$T"'

That generates the value, replaces any old line, restarts, and prints it once. Writing the command so it generates its own value is the point. An instruction that says PASTE_VALUE_HERE will end up in a config file as the literal words PASTE_VALUE_HERE, and you will find out weeks later as an unexplained 401.

Step 4: Build the door

This is the part most write-ups skip, so here is the whole thing.

The auth check. Constant-time comparison, and an unset key authorizes nobody rather than everybody. Security mistakes have a direction and this is the direction it has to fail in.

python
def _deploy_authorized(req) -> bool:
    narrow = settings.deploy_token or ""
    if not narrow:
        return False
    provided = req.headers.get("Authorization", "")
    return hmac.compare_digest(provided, f"Bearer {narrow}")

The route. Note what is missing: the request body is never read. No branch, no path, no options. It always ships origin/main.

python
@app.post("/deploy")
def deploy():
    if not _deploy_authorized(request):
        return jsonify({"error": "unauthorized"}), 401

    unit = f"selfdeploy-{int(time.time())}"
    script = "/opt/app/scripts/box_update.sh"
    if not os.path.exists(script):
        return jsonify({"error": "update script is not on this box"}), 500

    # A fixed argv and no shell. Nothing from the request reaches this.
    subprocess.Popen(
        ["systemd-run", "--unit", unit, "--collect",
         "--working-directory", "/opt/app", "/bin/bash", script],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
    )
    return jsonify({"status": "started", "target": "origin/main"}), 202

Why systemd-run and not just running it. The deploy restarts the very service answering this request. Work done in-process would kill its own reply mid-flight and report a failure that did not happen. Handing it to a transient unit means systemd owns it and it survives the restart.

Why 202 and never 200. 202 means started. 200 would mean done, and this endpoint cannot know that. The design admits what it does not know.

The update script keeps its own rails:

bash
flock -n 9 || exit 0                    # two deploys cannot interleave
git fetch origin
git merge --ff-only origin/main         # local drift fails loudly, never silently discarded
# snapshot the database before any migration runs
# install, migrate, restart the services

Fast-forward only matters more than it looks. If someone edited files on the server by hand, a merge would quietly bury that work. --ff-only refuses and tells you.

The health endpoint, unauthenticated, reporting the commit actually running:

python
@app.get("/health")
def health():
    return jsonify({"ok": True, "commit": running_sha(), "on_disk": head_sha()})

Two shas, not one, because of a failure nobody could see: the files moved and nothing restarted, so the deploy looked done while the running process was still on the old code. If commit and on_disk differ, a restart is owed.

Step 5: Hard-code the URL, keep only the token in the environment

Environment variables do not reliably reach a cloud session's shell. Mine did not, and the first live attempt failed on an empty URL before it ever touched the network, which looks exactly like a server problem and is not one.

Put the address in the command. Put only the secret in the environment.

Step 6: Ship

bash
curl -sS -X POST https://your.server/deploy \
     -H "Authorization: Bearer $DEPLOY_TOKEN"     # 202

curl -s  https://your.server/health               # poll this

7:48am, 30% battery. The 202 only says started. The two matching shas are the proof.

When the commit in the health response is the one you merged, it is live.

Calling the endpoint is not evidence. The reading is.

You are done when all three are true

Run these in order. If any one disagrees, stop and fix it before trusting the setup.

TestExpected
`curl -X POST https://your.server/deploy` with no header**401**
The same call with a wrong token**401**
The same call with the real token**202**
`GET /health` a minute later`commit` equals the sha you merged
`commit` vs `on_disk` in that responseidentical

Two 401s matter as much as the 202. An endpoint that ships code as root and has never been observed refusing anybody has not been tested.

Where each value lives

The most confusing part of the setup, and a mismatch shows up as a flat 401 with nothing else wrong to find.

ValueOn the serverIn the remote session
The deploy keyyes, it is checked hereyes, same string, sent from here
Your code-host tokenyes, the automation runs hereno, it does nothing there
The server URLnot neededhard-code it in the command

The deploy key is the only one that has to match in two places.

Tell it what it cannot do

Your remote session has no shell on the server. No logs, no database, no files. It knows exactly what the health endpoint tells it and nothing more.

Instruct it, in the charter, to say "merged, not yet deployed" rather than guessing, and never to claim something is live on the strength of having called the endpoint. It has to go and read.

An assistant that reports success it did not verify is worse than one that reports nothing, because you stop checking.

Two DNS traps that cost me an afternoon each

These bite when you give the server a public name. Both look like server faults and neither is.

A certificate is not automatic just because DNS resolves. If your edge issues certificates on demand, it may only do so for hostnames your application vouches for. DNS pointed at my server and the name still failed the TLS handshake. The fix was not a certificate command. It was telling the app that the hostname was its own. The first request then issued the certificate.

A wildcard DNS record captures every name you have not claimed. Mine pointed * at the app server, so a subdomain meant for object storage silently resolved to the wrong machine, which serves none of it. Nothing errors. You just get the wrong box. Connect a CDN or object-store subdomain in that provider's own dashboard so it creates the record itself; a specific record beats a wildcard.

And when you move a media host, keep the old one connected forever. Changing the public URL only affects new uploads. Everything already published still points at the old host and is typically cached for a year. Two hosts serving one bucket is the correct end state, not a mess to tidy up.

Three failures worth stealing

Not from this build specifically, but from the habit of checking, which is what the whole setup rests on.

A check that tested nothing, and passed. A privacy scan reported zero leaks. It had examined zero names, because the data lived under a different key. An empty result is not proof of absence. It is proof the instrument ran.

A test that never runs is green forever. A case added below the runner's entry point is never executed and the suite stays cheerfully green. Search the output for your new case's own label before you trust the count.

A rule that counted the wrong word. My merge gate required every check to report "success." Then CI got smarter and began reporting "skipped" for checks that did not apply, and the gate waited forever for a word that was never coming. Count success or skipped.

All three are the same failure wearing different clothes: something reported success without demonstrating it. That is the thread through this entire build, and it is why the health endpoint exists.

The rule that costs the most to learn late

A secret that appears in a chat log is burned. Not "rotate it later." Rotate it now.

The narrow key exists precisely so that when it happens (and it will), the blast radius is one redeploy of already-public code instead of your dashboard and your email.

Want an AI Brain built for your business?

Book a 20-minute call and tell me the job you want off your plate.