tmuxd
tmux + ttyd as a Python library: terminals that outlive the connection, that a program can type into and a person can open in a browser.
Everyone has written ttyd tmux new -A -s work at some point. tmux keeps the
session alive, ttyd makes it visible in a browser. It works — but the result has
no handle on it. Who opened that session, in which directory, is it still alive?
Want to feed it a command from the outside? SSH in and type tmux send-keys.
tmuxd is that command turned into something you import.
from tmuxd import Tmuxd
t = Tmuxd(port=12345, token="changeme") # ttyd is up; tmux is not yet
s = t.session(id="id5", cwd="~/proj", cmd="claude")
s.send("run the tests", enter=True)
print(s.url) # http://127.0.0.1:12345/?arg=id5
Send that URL to anyone and their browser is in that terminal — watching, and able to take over the keyboard. A program hands out the work; a person watches it run.
Inside a web backend that window needs no port of its own and no second
password: ttyd listens on a unix socket, t.asgi() mounts it under your own
routes, and your login decides who gets into which window — see
Where the window opens.
Quick start
Needs tmux (≥ 3.0) and ttyd on the machine — see Requirements.
As a library — no server needed
pip install tmuxd # zero runtime dependencies
from tmuxd import Tmuxd
with Tmuxd(port=12345, token="changeme") as t:
s = t.session(id="deploy", cwd="/srv/app", cmd="./deploy.sh")
print("watch it here:", s.url)
# ttyd goes with your process; the deploy is still running
Your process holds the instance, so there is nothing else to run.
Inside a web backend — the window on your port
pip install "tmuxd[asgi]" # + websockets; your ASGI server needs WebSocket support (e.g. uvicorn[standard])
from fastapi import FastAPI
from tmuxd import Tmuxd
app = FastAPI()
t = Tmuxd(base_path="/tty") # no port: ttyd listens on ~/.tmuxd/tmuxd/ttyd.sock (0600)
app.mount("/tty", t.asgi()) # the window, on your routes
@app.post("/api/work")
def start_work():
s = t.session(id="job-1", cmd="claude")
return {"window": s.url} # "/tty/?arg=job-1" -- same-origin, drop it in an iframe
One port, your app's: the window and your API share it.
No auth by default: anyone who can reach your app's port gets into the
window. That is enough when the host listens on 127.0.0.1 for yourself, or
something in front (Cloudflare Access, oauth2-proxy, a VPN) already
authenticates every request. On a public port, add an authorize:
def gate(scope):
"""Asked on every request into the window: the page, /token, /ws."""
return my_login_allows(scope) # your own login state, e.g. from a cookie
app.mount("/tty", t.asgi(authorize=gate))
Your app's /api auth middleware does not cover /tty — an iframe and a
WebSocket cannot send an Authorization header — so it needs its own gate.
The next section shows how to write one.
From the command line — needs a server
pip install "tmuxd[server]" # + fastapi + uvicorn
tmuxd start # both ports picked free, recorded in ~/.tmuxd/daemon.json
tmuxd new -s work -c ~/proj
tmuxd send -s work "npm test" --enter
tmuxd url -s work -o # open it in a browser
tmuxd ls
tmuxd stop # stops the server; sessions keep running
A CLI command lives for milliseconds and can hold neither ttyd nor session state, so it asks a server that can. That is why the CLI and the server install together — why.
Where the window opens: two doors
The ttyd page — the window a person looks through — can open in one of two places, decided by the constructor:
| ttyd on its own port (TCP) | mounted in your app (unix socket) | |
|---|---|---|
| Written as | Tmuxd(port=12345, token=…) |
Tmuxd(base_path="/tty") + app.mount("/tty", t.asgi()) |
| ttyd listens on | 127.0.0.1:12345 (or your bind) |
<state_dir>/<socket>/ttyd.sock, mode 0600 |
s.url |
http://127.0.0.1:12345/?arg=id5 |
/tty/?arg=id5 (relative, same-origin) |
| Who gets in | whoever has the token (basic auth, one for everyone) | by default, anyone who reaches your app; with authorize, your gate decides — per person, per window |
| Ports to open | one more, firewall included | none beyond your app's |
| Fits | scripts, one machine, the CLI (tmuxd start is always this) |
a web backend that already has a login |
The rule: port= (or TMUXD_PORT, or listen="tcp") means TCP; nothing at all means the socket.
⚠️ This is a breaking change. In 2.1.0,
Tmuxd()without a port picked a random free TCP port. Now it opens the socket,s.urlis relative, and nobody gets in until something mountst.asgi(). For the old behaviour writeTmuxd(listen="tcp"). See the changelog.
Why the second door exists. With its own port, the window sits outside your door: one more port for the firewall, and one more password — a plaintext token shared by everyone, unrelated to your login, so holding it skips the login entirely and neither logging out nor changing a password touches it. Mounted in your app, the window and the API share one port and one door.
Writing the gate. No authorize lets everyone in (see above). Given one, authorize(scope) gets the raw ASGI scope (path, query,
headers, cookies) and may be sync or async:
| Returns | Result |
|---|---|
| falsy | HTTP 403; the WebSocket handshake is closed (1008) |
True |
allowed |
[(name, value), …] |
allowed, with these headers added to the response — a cookie set after checking a ticket, say |
Why a public port needs a gate: the socket's 0600 only keeps other local users
off ttyd; once t.asgi() is mounted, anyone who can reach your app's port can
open /tty/?arg=<any id> and have a shell — looser than TCP mode's basic auth.
An iframe and a browser WebSocket cannot send an Authorization header, so the
usual shape is ticket for cookie: attach a short-lived one-time ticket to the
window URL, check it on the page request and set an HttpOnly cookie with
Path=/tty; let /ws in on that cookie and check that ?arg= is the session
on the ticket — one ticket, one window.
Worth knowing:
base_pathmust match the mount prefix ("/tty"with"/tty");- the relay moves bytes and never parses ttyd's protocol; ttyd's lifetime still
belongs to
Tmuxd(started at construction, stopped byclose()), and a gone ttyd is a 502; - keep the host at one worker; behind another proxy, raise the WebSocket idle timeout;
- the window is same-origin with your page, so ttyd's frontend JS can read your
page's localStorage. For a stricter line, serve
/ttyfrom its own subdomain.
Design and trade-offs: works/08 · one door.
What makes it different
It is designed by subtraction. What was removed says more than what is left.
- One session is one terminal. No windows, no panes — the multiplexing half of tmux is not used. Want more terminals? Open more sessions.
- Write only, no reading. No
capture, norun, no output stream, no recording, no event stream. Reading a terminal belongs to a person (open the URL — ttyd already does that better than any API could) or to ssh (clean stdout, a real exit code, binary safety). What stays is the one write action neither of them can do. - The facade is short-lived, the house is not. ttyd is a child of your
process; the tmux server is nobody's child.
kill -9your program and the sessions carry on, with their working directory and command remembered. - It never touches your own tmux. Only the binary is probed, and the pool
always opens on a dedicated
tmux -L tmuxd. Yourtmux lsis unchanged. - A person and a program type into the same terminal. Not a feature we built — tmux gives it away, which is why the whole design is arranged around it.
- No permission tiers. Everything is read-write. Getting into a window
means holding a shell on that machine, so a read-only switch here would be a
boundary that is not really there. Lock upstairs, where identity exists —
mounted in your app, upstairs is your
gate(scope).
Two ways in
| Library (mounted in your app) | Library (ttyd on its own port) | CLI | |
|---|---|---|---|
| Holds the instance | your process | your process | tmuxd serve |
| Needs a server | no (uses yours) | no | yes |
| Install | pip install "tmuxd[asgi]" |
pip install tmuxd |
pip install "tmuxd[server]" |
| Ports | none (ttyd is on a socket) | ttyd | ttyd + control API |
| The window | app.mount("/tty", t.asgi(…)) |
ttyd's port | ttyd's port |
| Programs call it | the library directly; mount tmuxd.server.router() to offer HTTP |
same | control API (random port) |
The CLI's two ports, two audiences. One is ttyd and it is for people — s.url goes
straight to a colleague. The other is the control API and it is for programs —
JSON in, JSON out, seven endpoints. Both are free ports picked at startup: 7681
is ttyd's own default, which makes it the likeliest port for your own ttyd to be
on, and claiming it would be picking a fight. They are recorded in
~/.tmuxd/daemon.json, which every tmuxd command reads, so you never type them. Driving another machine is ssh box tmuxd …,
not a port on the internet.
Requirements
| tmux | ≥ 3.0 | apt install tmux · brew install tmux · dnf install tmux |
| ttyd | ≥ 1.6 | bundled in the Linux wheels · macOS: brew install ttyd |
| Python | ≥ 3.9 | |
| OS | Linux, macOS |
On Linux, pip install is enough. The wheels carry an upstream ttyd build
for their architecture (x86_64, aarch64, armv7l — glibc and musl alike, since
upstream links statically). A ttyd already on PATH still wins: that one can be
fixed by apt upgrade and ours can only be fixed by a release of tmuxd.
On macOS you install ttyd yourself — brew install ttyd. Upstream has never
shipped a Darwin build (checked back to 1.7.3: ten musl ELFs and one win32.exe,
every time), and Homebrew's is dynamically linked against five of its own
packages, so re-shipping it would do badly what brew does well. macOS gets the
py3-none-any wheel, which installs everywhere and simply expects ttyd on PATH.
Windows is not supported — tmux has no Windows build, and tmuxd imports
fcntl.
Optional extras: tmuxd[asgi] adds websockets (for the ttyd side of
t.asgi()); tmuxd[server] adds fastapi + uvicorn (for the CLI and the
control API). import tmuxd itself always has zero dependencies.
If your machine is not ready — an architecture no wheel covers, no tmux, or
you want a newer ttyd than the one we vendored — there is an optional
tmuxd install. It fetches a checksum-verified ttyd
from upstream (falling back to the bundled one when the network is down) and
tells you the exact command for tmux on this machine, then records both paths in
~/.tmuxd.json so the library and the CLI find them next time. A ready machine
never runs it, and Tmuxd() does not check whether you have.
tmuxd never adopts the tmux you use yourself: it runs its own pool on a
dedicated socket, so tmux ls shows exactly what it showed before.
Development
pip install -e ".[dev]"
pytest # ~234 tests, ~70s
pytest tests/exact_targeting -v # a single scenario
Tests run against real tmux, real ttyd and a real uvicorn — this project's whole value lives at the seam with those programs, and mocking them would test nothing. Each test gets its own tmux socket, so running the suite never disturbs a tmux you have open. They are organised by scenario, not by module.
License
Apache-2.0 — see LICENSE.
tmuxd drives ttyd (MIT) and tmux (ISC) as external programs; it neither vendors nor modifies them.
Metadata
Release files for tmuxd 3.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| tmuxd-3.0.0.tar.gz | 192.7 kB | With provenance | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| tmuxd-3.0.0-py3-none-manylinux_2_17_x86_64.musllinux_1_2_x86_64.whl | Python 3 | none | Linux glibc 2.17+ x86-64, Linux musl 1.2+ x86-64 | Details |
| tmuxd-3.0.0-py3-none-manylinux_2_17_armv7l.musllinux_1_2_armv7l.whl | Python 3 | none | Linux musl 1.2+ ARMv7l, Linux glibc 2.17+ ARMv7l | Details |
| tmuxd-3.0.0-py3-none-manylinux_2_17_aarch64.musllinux_1_2_aarch64.whl | Python 3 | none | Linux glibc 2.17+ ARM64, Linux musl 1.2+ ARM64 | Details |
| tmuxd-3.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 2.7 MB
Release files / tmuxd-3.0.0.tar.gz
| Download URL | tmuxd-3.0.0.tar.gz |
|---|---|
| Size | 192.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1d1ed158cfa7f352f8942187259c7ede366826256d605cbb4a960163ce6a899d
|
|
BLAKE2b-256 checksum How to use checksums |
6c0eb5a6b029ad9b7005117a13972bcde3d04ac881c4f451b3699922b2898b9b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / tmuxd-3.0.0-py3-none-manylinux_2_17_x86_64.musllinux_1_2_x86_64.whl
| Download URL | tmuxd-3.0.0-py3-none-manylinux_2_17_x86_64.musllinux_1_2_x86_64.whl |
|---|---|
| Size | 811.5 kB |
| Tags | Linux glibc 2.17+ x86-64 Linux musl 1.2+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
20cd873448486f50f43b94d684233c0b23f2f40b9ef4cd7899a15a65490b011e
|
|
BLAKE2b-256 checksum How to use checksums |
5878db33351883efa696b8786b528fc07ee654e5dfe73b7d86c30e61a573fa5c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / tmuxd-3.0.0-py3-none-manylinux_2_17_armv7l.musllinux_1_2_armv7l.whl
| Download URL | tmuxd-3.0.0-py3-none-manylinux_2_17_armv7l.musllinux_1_2_armv7l.whl |
|---|---|
| Size | 779.8 kB |
| Tags | Linux glibc 2.17+ ARMv7l Linux musl 1.2+ ARMv7l Python 3 |
|
SHA-256 checksum How to use checksums |
95ce53a02511e4148426375dcc3c87b1579525ad700ecea2e5633a30e1dade15
|
|
BLAKE2b-256 checksum How to use checksums |
2589f25c80c6125955f5f91fbf6ed779ac5ee90d2c8ca1345853dfd3a47e5011
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / tmuxd-3.0.0-py3-none-manylinux_2_17_aarch64.musllinux_1_2_aarch64.whl
| Download URL | tmuxd-3.0.0-py3-none-manylinux_2_17_aarch64.musllinux_1_2_aarch64.whl |
|---|---|
| Size | 817.2 kB |
| Tags | Linux glibc 2.17+ ARM64 Linux musl 1.2+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
36969a0aef704416811deb5cabf54669ff49c2a142607fa07002f4ba428f2b29
|
|
BLAKE2b-256 checksum How to use checksums |
44eb19eb5897bb2987fa476c9b3e4986662ca6f474ca9f8d92071f1f7f72dbae
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / tmuxd-3.0.0-py3-none-any.whl
| Download URL | tmuxd-3.0.0-py3-none-any.whl |
|---|---|
| Size | 55.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f5f91329e2f764306df60120b49e47761ffd47a1be98dc222f4c837755b1d43f
|
|
BLAKE2b-256 checksum How to use checksums |
271b7c67ddd9ac86e415804913e973b971b7494aba81f63d65af88a98916d528
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logSecurity for tmuxd 3.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 5 files in this release have been uploaded with provenance metadata.
Attestation for tmuxd-3.0.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.