Skip to main content

tmuxd

PyPI Python License

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.

简体中文 · English · Changelog


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.url is relative, and nobody gets in until something mounts t.asgi(). For the old behaviour write Tmuxd(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_path must 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 by close()), 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 /tty from 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, no run, 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 -9 your 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. Your tmux ls is 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)

Source distribution for tmuxd 3.0.0
File Size Uploaded Provenance
tmuxd-3.0.0.tar.gz 192.7 kB With provenance Details

Built distributions (wheels)

Table of built distributions (wheels) for tmuxd 3.0.0
File
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 log

Release 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 log

Release 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 log

Release 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 log

Release 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 log

Security 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.

Select from 5 files

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 log

A 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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page