Drafter apps can be served over the network using SSH or Telnet. Each connecting client gets a full interactive TUI session in their terminal.
Session Modes
Two modes control how state is managed across connections:
:isolated(default) — each client runs an independent app instance with its own state. Suitable for single-user tools, personal dashboards, and games.:shared— all connected clients share a single app state. Any input from any client updates the shared state and triggers a re-render for everyone. Suitable for collaborative apps, shared dashboards, and multiplayer experiences.
SSH
SSH is the recommended transport. It handles authentication, encryption, and terminal capability negotiation automatically via Erlang's built-in :ssh application.
Starting an SSH server
:application.ensure_all_started(:ssh)
{:ok, _pid} = Drafter.Server.start_ssh(MyApp,
port: 2222,
mode: :isolated,
auth: [{"alice", "secret"}, {"bob", "secret"}]
)Clients connect with any standard SSH client:
ssh -p 2222 alice@localhost
Options
| Option | Default | Description |
|---|---|---|
:port | 2222 | TCP port to listen on |
:ip | {127, 0, 0, 1} | Interface to bind (use {0, 0, 0, 0} for all interfaces) |
:mode | :isolated | :isolated or :shared |
:auth | [{"admin", "admin"}] | [{username, password}] tuples, {username, password, props} triples, {:accounts, server}, or :anonymous |
:system_dir | auto-generated | Path to directory containing SSH host keys |
:mount_props | %{} | Map merged into props passed to mount/1 for every session |
:tunnel | false | true accepts ssh -R reverse forwards from clients |
:register_as | nil | With auth: {:accounts, server}, a username that opens the registration form; none by default |
:register_app | {Drafter.Accounts.RegisterApp, %{}} | The app the registration form runs, with props merged over accounts: and notify: |
SSH host keys
If :system_dir is not provided, Drafter generates RSA host keys automatically and caches them in a temp directory. Clients will see an "unknown host" warning on first connect.
For production deployments, generate persistent host keys and pass the directory explicitly:
mkdir -p /etc/drafter/ssh
ssh-keygen -t rsa -b 2048 -f /etc/drafter/ssh/ssh_host_rsa_key -N ""
Drafter.Server.start_ssh(MyApp,
system_dir: "/etc/drafter/ssh",
auth: [{"alice", "secret"}]
)Username in mount props
The authenticated username is always injected into mount/1 props as :username:
def mount(props) do
%{username: props.username, messages: []}
endAccounts and self-registration
For a server where people create their own accounts, start a Drafter.Accounts store
and hand it to the daemon:
{:ok, accounts} = Drafter.Accounts.start_link(path: "/var/lib/myapp/accounts.terms")
{:ok, _pid} = Drafter.Server.start_ssh(MyApp,
port: 2222,
auth: {:accounts, accounts},
register_as: "new"
)Someone with no account connects as the registration user with any password:
ssh -p 2222 new@host
They get a form asking for a username and a password, and once it is accepted the
session continues into MyApp under the new name, with props.username set. From
then on they connect as themselves. Passwords are stored as PBKDF2 hashes; an unknown
name costs the same to refuse as a wrong password; a peer address is locked out after
five failures in fifteen minutes; and a connection is dropped after three failures.
Each account has a props map the application owns — Drafter.Accounts.put_props/3
sets it, and it is merged into the session's mount props at login.
Held keys
An app declared with use Drafter.App, key_release: true asks each client's terminal
for the kitty keyboard protocol. Where the terminal supports it (kitty, Ghostty,
WezTerm, foot, Alacritty, iTerm2 3.5+), every key press is followed by
{:key_down, key, modifiers} and every release arrives as {:key_up, key, modifiers},
which is what an app that reacts to held keys needs. {:key_release_support, true}
is delivered once the terminal confirms, and Drafter.Session.Context.key_release?/0
answers the same question later. The {:key, ...} events are unchanged either way.
Reverse tunnels
With tunnel: true, a client can forward a port on the server back to its own
machine:
ssh -p 2222 -R <server port>:localhost:<client port> alice@host
Anything the server connects to on 127.0.0.1:<server port> reaches the client's
localhost:<client port>. Which port a session forwarded is the app's to know — an
account prop is one place to keep it.
Shared chat example
The examples/ssh_chat.exs example demonstrates a shared multi-user chat app:
elixir examples/ssh_chat.exs
Then connect from multiple terminals:
ssh -p 2222 alice@localhost # password: pass
ssh -p 2222 bob@localhost # password: pass
Messages sent by any user appear in all connected sessions in real time.
Telnet
Telnet is a simpler transport with no authentication or encryption. Useful for development, local network tools, or environments where SSH is unavailable.
Starting a Telnet server
{:ok, _pid} = Drafter.Server.start_telnet(MyApp,
port: 2323,
mode: :isolated
)Connect with any Telnet client:
telnet localhost 2323
Options
| Option | Default | Description |
|---|---|---|
:port | 2323 | TCP port to listen on |
:mode | :isolated | :isolated or :shared |
:mount_props | %{} | Map merged into props passed to mount/1 |
Supervision
For production use, start the server under your application's supervision tree:
defmodule MyApp.Application do
use Application
def start(_type, _args) do
:application.ensure_all_started(:ssh)
children = [
{Task, fn ->
Drafter.Server.start_ssh(MyTuiApp,
port: 2222,
auth: [{"admin", System.fetch_env!("TUI_PASSWORD")}]
)
Process.sleep(:infinity)
end}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
end