DEV Community

Marco Montanari
Marco Montanari

Posted on Originally published at github.com

stairway: from a chat idea to a running Claude Code experiment on my own server

Most of my side projects start the same way: I'm on my phone, talking an idea through with Claude, and at
some point the conversation is good enough that I want to try it. Then the momentum dies. I have to get to
a laptop, SSH into my server, make a folder, start Claude Code, and paste in a summary of everything we just
discussed.

So I built stairway: a small MCP server that lets the chat do that
step itself.

"Ok, let's try it. Create an experiment called rss-digest and brief it with what we said."

A few seconds later there's a folder on my server, a git repo in it, and a Claude Code session already working
on the brief. The session shows up in claude.ai/code, so I can follow it from my phone.

What it does

stairway is one Python file, standard library only (Python 3.8+), about 1,700 lines. It exposes these MCP
tools:

  • create_experiment(name, prompt) creates <root>/<name>, runs git init, and starts claude --remote-control <name> "<prompt>" inside a detached screen.
  • send_prompt(name, prompt) passes more instructions to that running session, mid-conversation.
  • attach_folder(path) lets Claude use an existing project, but only after I press Approve on stairway's status page.
  • experiment_status, open_editor, stop_experiment, forget_experiment and friends.

Around those tools there are three ways to work on an experiment:

  1. claude.ai/code, because every session runs with remote control on.
  2. A browser VS Code, started on demand per experiment and proxied by stairway. The first terminal joins the Claude session, and every other terminal is a normal shell in the folder.
  3. A Telegram group. Add the bot, approve the group, /new picnic-planner <brief>, and from then on you talk to Claude by mentioning the bot, while the humans discuss what it builds. Its replies, permission requests and files come back to the group.

The parts I found interesting

An OAuth 2.1 server with no storage

My first version used a token in the URL. claude.ai reached the server and got a valid initialize
response… and then went looking for OAuth anyway (/.well-known/oauth-protected-resource, dynamic client
registration at /register), and gave up when those returned 404.

So stairway is its own small OAuth 2.1 authorization server: discovery documents, dynamic client
registration, PKCE (S256), refresh tokens. The twist is that nothing is stored. Client IDs, access tokens
and refresh tokens are all HMAC-signed blobs:

def _sign(payload):
    body = _b64(json.dumps(payload, separators=(",", ":")).encode())
    return body + "." + _b64(hmac.new(TOKEN.encode(), b"oauth." + body.encode(), hashlib.sha256).digest())
Enter fullscreen mode Exit fullscreen mode

A registered client is just {"k": "client", "r": [redirect_uris], "n": name}, signed. Only the short-lived
authorization codes live in memory. As a bonus, rotating the one server secret revokes every grant at once.

Typing into a TUI, reliably

claude --remote-control gives you an interactive session that's also visible on claude.ai. To pass it more
prompts, stairway types into the screen it runs in. A naive screen -X stuff "text\r" breaks on multi-line
prompts, since each newline submits. It also mangles ^ and \.

The fix is a bracketed paste: load the text into screen's paste buffer from a file, wrapped in
ESC[200~ … ESC[201~, paste it, then press Enter.

f.write("\033[200~" + prompt + "\033[201~")
subprocess.run(["screen", "-S", session, "-X", "readbuf", "-e", "utf8", path])
subprocess.run(["screen", "-S", session, "-X", "paste", "."])
_stuff(session, "\r")
Enter fullscreen mode Exit fullscreen mode

Claude Code sees it as one paste, multi-line and with every character intact, and queues it if it's busy.

Getting answers out: Claude Code hooks

For Telegram I needed Claude's replies, and scraping a TUI is miserable. Claude Code's hooks solve this. The
Stop hook payload includes last_assistant_message, so each session starts with:

{"hooks": {
  "Stop":         [{"hooks": [{"type": "command", "command": "curl -s --data-binary @- 'http://127.0.0.1:8765/hooks/<name>/stop?k=<key>'"}]}],
  "Notification": [{"hooks": [{"type": "command", "command": "curl -s --data-binary @- '…/notify?k=<key>'"}]}]
}}
Enter fullscreen mode Exit fullscreen mode

stairway relays the reply to the bound Telegram group, as a reply to the message that asked. Permission
prompts arrive as "⏳ needs your permission", which the group can answer with /approve or /deny. An
--append-system-prompt tells Claude which curl sends a file it made to the group, so charts and images
just show up there.

The red bars

Inside screen, Claude Code's UI was covered in solid red bars. Capturing the raw output showed why: Claude
draws with 24-bit colors (ESC[38;2;153;153;153m), and screen 4.x doesn't understand them. It reads 38,
2, 153, … as separate attributes, and some of those numbers mean "red background". Setting
COLORTERM or FORCE_COLOR didn't help. Starting the session with Claude Code's ANSI theme did:

--settings '{"theme": "dark-ansi"}'
Enter fullscreen mode Exit fullscreen mode

It's set per process, so my own Claude Code theme stays as it is.

A browser VS Code without dependencies

code-server listens on a unix socket per experiment, and stairway proxies /code/<name>/ to it with a
deliberately dumb byte pipe. It forwards the request head, then shovels bytes both ways with select() until
either side closes. Websockets just work, and plain requests get Connection: close so a keep-alive
connection never gets mixed up between routes.

Security, honestly

stairway's purpose is to let an AI agent run commands on your machine when asked. So:

  • one strong secret, OAuth with your explicit consent, and HTTPS in front
  • existing folders and Telegram groups do nothing until you approve them on the status page
  • choose each experiment's Claude Code permission_mode deliberately; default asks before acting
  • run it as a dedicated user, ideally on a box you're happy to give an agent

The repo has a security doc with the full
threat model.

Try it

git clone https://github.com/sirmmo/stairway.git && cd stairway
cp .env.example .env   # set STAIRWAY_TOKEN, STAIRWAY_PUBLIC_URL, ...
set -a; . ./.env; set +a; python3 stairway.py
Enter fullscreen mode Exit fullscreen mode

Put it behind HTTPS and add https://<host>/mcp as a custom connector in claude.ai. Then ask Claude to
create your first experiment. You'll need Claude Code logged in with a claude.ai subscription on the server,
because remote control requires it. GNU screen is required, and code-server and a Telegram bot token are
optional.

I built it in a morning of pair-programming with Claude Code, which was a fitting way to make a tool for
starting Claude Code sessions. It's MIT licensed. Issues and PRs are welcome:
github.com/sirmmo/stairway.

What would you want your chat to be able to spin up?

Top comments (0)