Skip to main content
Loss of provenance

A previous release had provenance metadata, but this release does not. View details.

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

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Loss of provenance

These files do not have provenance metadata, despite files in a prior release including this data.

We recommend further investigation before installing these files. View details.

Source distribution (sdist)

Source distribution for tmuxd 3.0.1
File Size Uploaded Provenance
tmuxd-3.0.1.tar.gz 196.9 kB Without provenance Details

Built distributions (wheels)

Table of built distributions (wheels) for tmuxd 3.0.1
File
tmuxd-3.0.1-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.1-py3-none-manylinux_2_17_armv7l.musllinux_1_2_armv7l.whl Python 3 none Linux glibc 2.17+ ARMv7l, Linux musl 1.2+ ARMv7l Details
tmuxd-3.0.1-py3-none-manylinux_2_17_aarch64.musllinux_1_2_aarch64.whl Python 3 none Linux musl 1.2+ ARM64, Linux glibc 2.17+ ARM64 Details
tmuxd-3.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 2.7 MB

Release files / tmuxd-3.0.1.tar.gz

Loss of provenance

This file belongs to a release without provenance metadata, despite a prior release including this data.

We recommend further investigation before installing this file. View details.

Download URL tmuxd-3.0.1.tar.gz
Size 196.9 kB
Tags Source
SHA-256 checksum
How to use checksums
a6c2b074356ca11960a56e478965b73589a17b9b208ae59a74e63f0fc41bb4a9
BLAKE2b-256 checksum
How to use checksums
869f0bb573ccf290fc05580b47f80f2c624e705e239ff3b0a0ac7616356e5963
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.2

Release files / tmuxd-3.0.1-py3-none-manylinux_2_17_x86_64.musllinux_1_2_x86_64.whl

Loss of provenance

This file belongs to a release without provenance metadata, despite a prior release including this data.

We recommend further investigation before installing this file. View details.

Download URL tmuxd-3.0.1-py3-none-manylinux_2_17_x86_64.musllinux_1_2_x86_64.whl
Size 811.8 kB
Tags Linux glibc 2.17+ x86-64 Linux musl 1.2+ x86-64 Python 3
SHA-256 checksum
How to use checksums
d8ed94605e3b229b069f0352d757d82c62af38c159a8aa7fe7649834a04f8654
BLAKE2b-256 checksum
How to use checksums
56bb20a15cd780d5243182df3e09a59a2ccd93a2666f37a6be1e0b09ab29f309
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.2

Release files / tmuxd-3.0.1-py3-none-manylinux_2_17_armv7l.musllinux_1_2_armv7l.whl

Loss of provenance

This file belongs to a release without provenance metadata, despite a prior release including this data.

We recommend further investigation before installing this file. View details.

Download URL tmuxd-3.0.1-py3-none-manylinux_2_17_armv7l.musllinux_1_2_armv7l.whl
Size 780.1 kB
Tags Linux glibc 2.17+ ARMv7l Linux musl 1.2+ ARMv7l Python 3
SHA-256 checksum
How to use checksums
da4dc08513c9c089f86bd5cb905073ec5b741556677726c5b0bd0b24d6e2fa84
BLAKE2b-256 checksum
How to use checksums
1f8fb3a9e730d8240ea1981cafbca3dabeb0bbdf14ff619f2159db3f5e7b5290
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.2

Release files / tmuxd-3.0.1-py3-none-manylinux_2_17_aarch64.musllinux_1_2_aarch64.whl

Loss of provenance

This file belongs to a release without provenance metadata, despite a prior release including this data.

We recommend further investigation before installing this file. View details.

Download URL tmuxd-3.0.1-py3-none-manylinux_2_17_aarch64.musllinux_1_2_aarch64.whl
Size 817.5 kB
Tags Linux glibc 2.17+ ARM64 Linux musl 1.2+ ARM64 Python 3
SHA-256 checksum
How to use checksums
45930df735ee46931a7a416175979a4fb41111665d9273b981dfc606c152fbf7
BLAKE2b-256 checksum
How to use checksums
55e36d7468e60f48d383ba8a67e06973ee2de3e263ee8f8760c7319827e06786
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.2

Release files / tmuxd-3.0.1-py3-none-any.whl

Loss of provenance

This file belongs to a release without provenance metadata, despite a prior release including this data.

We recommend further investigation before installing this file. View details.

Download URL tmuxd-3.0.1-py3-none-any.whl
Size 57.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f1edb58e3f7a900c737b7b87221ae40c7608159b2021984d65d477d10ef20f5a
BLAKE2b-256 checksum
How to use checksums
157e0f9f1c4a1e1ab97fc1a85c0f253f561b684572402438436de94d54aba25f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.2

Security for tmuxd 3.0.1

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.

Loss of provenance

A previous release of these files had provenance metadata, but this release does not.

While this could reflect a routine change by the project maintainers, it may also indicate that the release files have been compromised.

Release comparison

  • Removed 3.0.0 5 files With provenance
  • Added 3.0.1 5 files Without provenance

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