Halfway through a normal Thursday we decided our desktop should stop being “a place we run Claude” and become a real agent station: its own repo, its own identity, its own tooling, and a way to dispatch work to it from a phone. By that evening it had all of those, and we could SSH into it from an iPhone over Tailscale and kick off a job while standing in line at a store.

We write this up because of something we learned the hard way earlier that same day: if you run agents across more than one machine and you do not give each machine an explicit identity, the agents start guessing about what they are allowed to touch. And a guessing agent with shell access and a git remote is exactly the thing you do not want.

The close that started it

That morning we closed a production ticket from the wrong machine. The code lived in one of our long-lived git clones; the session we closed it from was rooted somewhere else entirely. We drove the whole thing from the outside over the JIRA REST API and the git CLI: merge to the release branch, push, clean up the feature branch, fire the JIRA transition, verify in a browser. It worked. It was also our first cross-station deploy, and it shook loose seven small frictions in a single sitting.

A few of those frictions were pure environment mismatch. Windows has no /tmp, so writing a scratch JSON payload to /tmp/whatever.json just failed and we had to use $TEMP. Inline Python inside a bash heredoc died on the quoting once you nest an f-string inside a heredoc inside a Windows shell. git branch -d refused to delete the local feature branch because it carried the merge commit the remote did not have yet, so the fix was to delete the remote branch first, then the local one.

None of those are interesting on their own. Every one of them was a default waiting to be encoded. The session had no idea which machine it was on, what that machine could reach, or which conventions applied here versus there. It was acting like a generic shell, because nothing told it otherwise.

So we decided the desktop needed to be a named thing with a written-down identity.

What a station actually is

We cloned a fresh repo for it and seeded four things: a README, a CLAUDE.md, a stations doc, and a scripts folder. The CLAUDE.md is the part that matters. It is a per-machine identity doc, and it answers three questions before any agent does any work:

  • What is this station? Ours declares itself an operational orchestrator. Not a knowledge store, not a code host. Its job is to drive other things.
  • What can it reach? Local repos on this disk. Git push to our own GitHub org over SSH. An SSH hop to our always-on Linux box. Local shell, local Python, local Node.
  • What can it NOT reach? This is the load-bearing half. No Google Drive. No email. No production servers, no hosted services, no paid APIs without explicit credential setup.

That “cannot reach” list is the whole point. An agent that knows its own boundaries does not invent a path to a production database because it sounds plausible. It does not assume it can read email because email exists in the world. The identity doc converts “the model will probably behave” into “the model has been told, in writing, what authority it has here.”

We also wrote a separate stations doc that holds the access matrix across every machine we run agents on: which station owns operations, which holds knowledge, which sits always-on and reachable. When we promoted the desktop to operational primacy, we updated that doc in the same beat so the topology was canonical and not folklore. The next agent that boots on any of these machines reads one file and knows the map. It does not reconstruct it from clues.

The skill family and the tooling

A station with an identity but no hands is just a config file. So the same day we built out a small skill family for the common loop: opening a ticket, syncing state, picking what is next, logging what happened, closing out. Behind the skills sit plain Python scripts: a session tracker, a work-log writer, and a board generator that renders a live dev board so we can see every clone’s state at a glance instead of walking ten of them by hand.

The split is deliberate. A skill is a markdown procedure, the human-readable “how we do this here.” It dispatches to executable Python that does the actual work. That separation is what lets the seven frictions from the morning become permanent fixes instead of notes we promise ourselves we will remember. The merge-order lesson, for example, does not live in anyone’s head anymore. It is the only entry point the git wrapper exposes, so you physically cannot do it backwards.

There is a second, sharper reason the tooling has to be raw Python and not our usual token-saving CLI wrapper. We run a wrapper that compresses verbose command output to save AI tokens, and it saves a lot of them. But it is structural: it recognizes a git log or an API response and hands back a tidy summary instead of the bytes. That is fine for a human reading along. It is a correctness bug for an agent that needs to verify a merge SHA or read back an exact API field, because the agent is parsing, not reading. So the station’s scripts are banned from that wrapper. They use raw subprocess and raw HTTP. On the paths that must be authoritative, lossy-but-pretty is worse than nothing.

Giving it a phone

The last piece was reach. We installed OpenSSH Server on the Windows desktop, locked it to key-only auth, pointed the shell at Git Bash, and exposed it over Tailscale so nothing is sitting open on the public internet. Then we put Termius on an iPhone and added a couple of short aliases so the desktop reaches feature-parity with our always-on Linux box for dispatching work.

We jogged to a store mid-day. While we were out, a teammate approved the ticket we had been waiting on. In the old world that approval sits until we are back at the keyboard. In the new world we could have opened Termius, SSHed into the desktop over Tailscale, and dispatched the next step from the sidewalk. “Send work to our desktop” became a thing we can do from a phone, with our keys, over a private network, against a station that knows exactly what it is allowed to do.

That last clause is why the identity doc has to come first and the phone second. A phone that can SSH into a machine whose agent has no sense of its own boundaries is a way to fire an unbounded agent from anywhere. The order matters: name the station, write down what it can and cannot touch, and only then hand it a phone.

Takeaway

Before an agent runs on any machine, check that the machine has a written identity: what it is for, what it can reach and, most important, what it cannot reach. Add remote access such as SSH from a phone only after that list exists, because a phone with access to an agent that has no stated boundaries is a way to fire an unbounded agent from anywhere.