← Back to Writings

Use OpenCode From Anywhere via a Telegram Bridge

How I reach OpenCode from anywhere through a Telegram bridge, so I can code from my phone while away from my desk, with no tunnel, no open ports, and nothing exposed to the internet.

May 31, 2026
OpenCodeCoding AgentTelegramRemote AccessNATSSEAPISelf-HostingDeveloper Workflow

The Setup

I do most of my real work in OpenCode, a terminal coding agent that runs on the Mac at my desk. It has my projects, my sessions, my context. The problem is obvious the moment I step away: if I am not in front of that machine, I cannot see what the agent is doing, cannot answer its questions, cannot kick off a task while an idea is fresh. The agent is stuck on a machine I am no longer sitting at.

I wanted to reach it from my phone. Start a task on the train, check the result over lunch, then walk back to my desk and continue in the terminal as if nothing had moved. This is the story of how the obvious solution was the wrong one, and how flipping the direction of the connection made the whole thing simpler, cheaper, and safer.

The Obvious Answer (And Why I Dropped It)

OpenCode already ships with a server mode. You can run it headless as an HTTP server with opencode serve, or with a browser UI via opencode web, and attach a local TUI to a running instance with opencode attach. So my first instinct was the standard one: run the web UI on the Mac, put it behind a tunnel, open it on my phone.

That works, but it drags in three problems I did not want:

  1. It is inbound. My Mac sits behind a home router. To reach it from cellular data, the phone has to connect into the machine, and NAT blocks that by default. Fixing it means a tunnel: Tailscale, a Cloudflare tunnel, or port forwarding. More moving parts, more things to keep alive.
  2. The server is unsecured by default. On startup it literally prints a warning that no password is set and the server is open. Anyone who can reach that port can execute code on my host. Exposing that to the internet, even briefly, is not something I want to do casually.
  3. A browser on a phone is a bad place to babysit a coding agent. I did not want a tiny web UI. I wanted to chat with it the way I already chat with everything else.

So I stepped back and asked a different question. Instead of “how do I let my phone reach into my Mac,” what if the Mac reaches out to me?

The Insight: Bridge Out, Do Not Expose In

Here is the part that made everything click. NAT blocks inbound connections. It does not block outbound ones. Every device behind a home router makes outbound connections constantly, that is how the whole internet works from inside a LAN.

A chat platform like Telegram is built around exactly this. A bot does not receive a connection, it makes one: it long-polls the platform’s servers, outbound, and messages flow back over that same connection. So if I run a small bridge process on the Mac that connects out to Telegram on one side, and talks to OpenCode on 127.0.0.1 on the other, the NAT problem disappears entirely.

Phone (Telegram app)  <->  Telegram servers  <->  Bridge (on the Mac)  <->  opencode serve (127.0.0.1)

No tunnel. No open port. No Tailscale. Nothing exposed to the public internet. OpenCode’s server stays bound to localhost where only the bridge can reach it, and the bridge only ever makes outbound calls. The machine is as closed to the outside world as it was before.

This reframing is the whole article, really. The rest is plumbing.

Verify the API, Do Not Trust the Docs

Before writing a line of the bridge, I probed OpenCode’s running server instead of assuming how it behaved. The server exposes its full OpenAPI spec at a documentation endpoint, so I pulled that on the exact version I had installed (1.15.x) and read the real method-and-path list. A few things would have cost me an afternoon if I had guessed:

  • Sending a prompt is done through an async endpoint that returns 204 No Content and delivers the reply over the event stream, not in the HTTP response. The plain, non-async path I expected to exist returned HTML on this build. If I had coded against the obvious name, I would have spent an hour wondering why my JSON parser was choking on an HTML page.
  • There are two event endpoints, and only one of them actually streams turn output. The other only ever emitted a single “connected” event and went quiet. The right one is a global stream that wraps every event with the project directory it belongs to, which is exactly what you need to route output back to the correct chat.
  • The permission gate (where the agent asks “can I run this command?”) uses a reply enum of once / always / reject. Not allow / deny, which is what I would have typed from memory.

Reading the spec on the actual binary, rather than trusting a tutorial or my own assumptions, is the cheapest debugging I did all night.

The Question That Actually Mattered: Session Continuity

The feature I cared about most was this: start a task from my phone while away, then finish it in the OpenCode TUI at my desk, in the same conversation with full history. If the phone and the desk are two separate silos, the whole thing is a toy.

So I tested it directly instead of hoping. The result: the server and the TUI share one database. OpenCode 1.15.x stores sessions in a single SQLite file, indexed per project, where a project is just the hashed path of the working directory. When I started the server in a folder, it returned the exact sessions the TUI had created there earlier, with full message history. They are not two systems that sync. They are one system with two front doors.

That means the away-on-phone, home-at-desk pattern works for free. Kick off a task from Telegram on the road, then run opencode in that same folder at the desk and the conversation is right there. The only thing to avoid is writing to the same session from both at once, which is a SQLite contention problem, not a design one. Alternating is completely safe.

Security: The Real Boundary Is the Allowlist

Even bound to localhost, I did two things to sleep well:

  1. Set a server password. The server supports HTTP Basic auth with a fixed username, so the bridge sends an Authorization header on every request, including the event stream. With it set, even reading the docs endpoint requires the header. One gotcha worth flagging: if you test the auth with a masked or placeholder password, you get a 401 and wrongly conclude the header name is wrong. Use the real value, the scheme is correct.
  2. Lock the bridge to one chat identity. This is the actual security boundary. The bridge ignores every message that does not come from my own user ID. The chat platform allowlist is what keeps strangers out, not the localhost binding alone. The agent’s own permission prompts still fire for risky actions on top of that.

If you take one thing from this section: the localhost binding stops the network from reaching the agent, and the chat allowlist stops people from reaching the bridge. You need both.

Bridge Design Notes

A few decisions that made the experience pleasant rather than just functional:

  • Throttle the streaming. A coding agent emits a torrent of tokens. Forwarding each one would hit the platform’s rate limit instantly (Telegram is roughly one message per second per chat). Instead the bridge edits a single message in place on a timer: “working…” becomes “done.” One message, updated, not a hundred new ones.
  • White text versus grey activity. The agent’s output splits into final answer text, internal reasoning, and tool activity. I forward the answer text in full, and condense tool activity into a single line like “read X, edited Y, ran the build.” The verbose reasoning sits behind an opt-in toggle. Crucially, this filtering is pure plumbing in the bridge. It costs zero extra tokens, the only model usage is the agent’s own, exactly as if I were typing at the desk.
  • Permission prompts become buttons. When the agent asks to run something, the event becomes inline buttons for once, always, and reject. Tap one, the bridge posts the reply back. Worth knowing: Telegram caps button payloads at 64 bytes, so you cannot stuff a full path and request id in there. Keep a tiny local token-to-payload map and put a short token in the button instead.
  • One chat thread per session. Telegram’s group Topics map cleanly onto sessions: one topic is one conversation in one project folder. (Topics are a group feature, they do not exist in private chats, which surprised me mid-build.)

Pitfalls I Hit

  • Python buffering under nohup. I backgrounded the bridge and its log file stayed empty, which made me think it had crashed silently. It had not. Python was buffering stdout. Running it unbuffered fixed the blindness immediately. An empty log is not the same as a quiet process.
  • An API path returning HTML is usually your fault, not the server’s. Twice I saw HTML where I expected JSON and almost concluded an endpoint was broken. Both times it was a quoting or piping artifact in my own probe. Pipe the raw body to head before blaming the server.
  • The default port is random. The server binds a random port unless you pass one explicitly. Always pin it.

The Result

I can now open a chat thread from anywhere, pick a project folder, attach to an existing OpenCode session or start a fresh one, and talk to it like I am sitting at the desk. It edits files, runs builds, shows diffs, and asks permission with tappable buttons. When I get home, I open the OpenCode TUI in that same folder and the conversation is right there, history intact, because it was never a separate thing.

The lesson I keep relearning: when something behind NAT feels hard to reach, check whether you actually need it to be reachable. Often the cleaner move is to have it reach out instead. Inbound is a tunnel and a security headache. Outbound is just a process making a phone call.

Found this useful?

I write about building resilient software, macOS development, and practical engineering. If you have a project in mind, let's talk.

Work with me