alktty 0.5.0

Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels.
Documentation
---
status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
  alknet-tty-local → alktty's `local` feature module, alknet/tty →
  alk/tty, alknet-core → alkcall::core, alknet-call → alkcall, ADRs
  renumbered 052..093 → 001..009)
last_updated: 2026-09-05
---

# alktty — Overview

The terminal session protocol crate: a `ProtocolHandler` on `alk/tty`
that pumps a bidirectional byte stream (stdin/stdout/stderr) with a JSON
control channel (resize, signal, eof, exit) over a framed bidi stream,
decoupled from the backend that allocates the PTY via a `TtyBackend`
trait. This document covers the crate's purpose, the two-carriage model
in brief, its dependency edges, the ALPN, and the backend location map.
Component details are in the sibling documents.

## What

`alktty` is the terminal session protocol crate for the
ALPN-as-service architecture (alknet ADR-001). It registers the `alk/tty`
ALPN on the shared endpoint and implements the `ProtocolHandler` trait
(alknet ADR-002, alknet ADR-007). The `TtyAdapter` receives a
`Connection`, accepts one bidi stream per terminal session, reads a
single JSON negotiation frame, switches to a raw chunk format, and
pumps bytes bidirectionally for the life of the session —
backend-agnostic.

The guiding insight that shapes the crate:

> A terminal session is not an SSH concern, or a Docker concern — it is
> a terminal concern. SSH and Docker are just two backends that can
> allocate a PTY.

The alknet-docker POC proved that the hard part of interactive attach —
bidirectional byte pumping over a framed stream with a 1-byte
stream-type multiplexer — is the same problem regardless of whether the
backend is `bollard::attach_container()` or russh's `pty_request`. The
POC's raw chunk format is the seed of alktty's wire format. alktty
extracts that pattern into its own crate and ALPN; the backends (Docker,
SSH, local process) implement a `TtyBackend` trait; the `alk/tty`
handler is backend-agnostic. This dissolves the PTY hedge in the
alknet-ssh research (DP-5): PTY is not an SSH feature delegated to a
separate crate, it's a tty feature that SSH happens to be able to
provide.

## Why

The crate's purpose is to be the terminal session library for downstream
consumers. A hub that runs agent workspaces in containers wires
`DockerTtyBackend` into the `TtyAdapter` and gets interactive terminal
sessions over `alk/tty`. A coordinator that runs `cargo test` remotely
wires `LocalTtyBackend` (pipe mode) and gets the runner pattern (a
process whose stdin/stdout/stderr/exit-code stream over a framed bidi
connection) — the same shape as GitHub/Gitea Actions runners, just over
alk's transport instead of HTTP polling. A browser terminal (xterm.js
over WebTransport, when WebTransport revives) connects to `alk/tty`
directly and gets raw bytes without implementing SSH or the call
protocol.

The key architectural insight: **the wire format and the backends invert
at the `TtyBackend` trait.** alktty owns the wire format, the negotiation
frame, the chunk codec, the control channel, and the session lifecycle;
the backends own the PTY allocation (docker exec with `tty: true`, russh
`pty_request` + `shell_request`, `portable_pty::openpty`). The adapter
is backend-agnostic and testable with a mock backend (in-memory pipes).
See [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md).

## The Two-Carriage Model in Brief

A `alk/tty` bidi stream has two phases (full detail in
[tty-wire.md](tty-wire.md), decided in
[ADR-001](decisions/001-wire-format-and-two-carriage.md)):

1. **Negotiation (JSON carriage).** The client writes a single
   length-prefixed JSON frame carrying the terminal parameters, backend
   selector, command, and environment. The framing is a 4-byte
   big-endian length prefix + UTF-8 JSON body, self-contained in
   alktty (the format coincides with alkcall's framing by convention;
   alktty does not depend on alkcall's internal wire types — see
   Dependencies below).

2. **Raw carriage.** After the negotiation frame, the stream switches to
   the chunk format (`[stream_type: u8][length: u32 be][payload]`) for
   the life of the session. Five stream types: 0=stdin (client→server),
   1=stdout (server→client), 2=stderr (server→client), 3=ctrl-in
   (client→server, JSON control messages: resize, signal, eof),
   4=ctrl-out (server→client, JSON control messages: exit). There is no
   `call.responded`/`call.completed` — this is not the call protocol;
   the raw-carriage byte pump is its own wire format after the single
   JSON negotiation frame.

This is the pattern the docker POC validated and the SSH research
independently arrived at: JSON for the structured request, raw bytes for
the body, which is the part that is actually bytes. The full rationale
(why not JSON for everything; the two-carriage decision; the Phase 7
control-channel split) is in
[ADR-001](decisions/001-wire-format-and-two-carriage.md) §Context.

## Dependencies

```
alktty (default — wasm-clean)
├── alkcall::core   (ProtocolHandler, Connection, AuthContext, Identity, AccessControl,
│                    OwnershipProvider — alknet ADR-050 for terminal sessions as resources)
└── (no backend deps — portable_pty, bollard, russh are in the backend crates / `local` feature)

alktty (local feature) — non-wasm by design
└── adds: portable-pty, tokio-util, tokio/process, tokio/rt-multi-thread
```

alktty is dependency-light: alkcall (the handler interface and auth)
only. The negotiation framing is a self-contained ~30-line module in
alktty (4-byte BE length prefix + UTF-8 JSON body on tokio's
`AsyncRead`/`AsyncWrite`). The heavy backend dependencies
(`portable_pty`, `bollard`, `russh`) live in the backend crates, not
here. alktty does **not** depend on alkcall's internal wire types — see
[ADR-006](decisions/006-negotiation-framing-self-contained.md).

### Why no alkcall-internal-wire-types dependency

An earlier draft had alktty depending on alkcall for the
`FrameFramedReader`/`FrameFramedWriter` "framing utility." A
pre-implementation check found this was unsound: `FrameFramedReader`'s
`read_frame()` is hardcoded to deserialize `EventEnvelope` — the
length-prefix read and the type-specific deserialize are one entangled
call, not a separable utility. alktty's negotiation payload is a
`NegotiateRequest`, not an `EventEnvelope`, so the claimed reuse did not
exist in a usable form. alktty implements its own framing (the format
coincides with alkcall's by convention; the implementations are
independent). The ~30 lines of length-prefix framing is an idiom, not a
domain abstraction worth a cross-crate dependency. See
[ADR-006](decisions/006-negotiation-framing-self-contained.md) for the
full decision and alknet ADR-003 Amendment 2 for the dependency-edge
clarification.

alktty stays lean — it has no `portable_pty` (default), no `bollard`, no
`russh`, no alkcall-internal-wire-types. The `TtyBackend` implementations
are opaque `Arc<dyn TtyBackend>` from the adapter's perspective:
constructed by the assembly layer at startup, stored in the adapter's
backend map, dispatched by the `backend` field of the negotiation frame.

## ALPN

| ALPN | Handler | Transport | Browser? |
|------|---------|-----------|----------|
| `alk/tty` | `TtyAdapter` | QUIC bidi stream (direct) or `alk/channels` (multiplexed) | Yes (when WebTransport revives — alknet ADR-040 parked) |

`alk/tty` is a custom ALPN per the alknet ADR-006 `alk/<name>` convention
(renamed from `alknet/<name>` in alkcall 0.1.1). The `TtyAdapter`
registers for it; the endpoint's `HandlerRegistry` maps `alk/tty` to the
adapter instance. One ALPN per connection (alknet ADR-006); within a
connection, multiple bidi streams carry independent sessions (one
session per stream — see [tty-adapter.md](tty-adapter.md)).

The browser terminal case: a browser (xterm.js) connects via WebTransport
to `alk/tty` and gets raw bytes. The browser doesn't need to implement
SSH or the call protocol for the terminal use case — only if it wants
SSH-specific features (port forwarding, SFTP). This is a cleaner browser
story than "run a WASM SSH client." WebTransport is deferred per
alknet ADR-044; when it revives, the `alk/tty` ALPN is reachable over
WebTransport's ALPN-stream-proxy (alknet ADR-040, parked).

### Channels mode (ADR-008)

The same `alk/tty` protocol also runs inside an `alk/channels`
connection: a channel with ALPN `alk/tty` carries the TTY session, and
the channels layer strips its 8-byte header before handing TTY the
payload. The same `wire.rs` code runs in both modes; only the `BiStream`
source differs. See [ADR-008](decisions/008-channels-pure-channel-multiplexing.md)
and [tty-adapter.md](tty-adapter.md).

## Backend Location Map

The decomposition principle: the trait lives where the types live
(alktty); the implementations live where their transport dependencies
live.

```
alktty (default — lean, no portable_pty, no bollard, no russh)
├── TtyBackend trait             (the contract — ADR-002)
├── TtyHandle, TtyControl        (the handle shape backends produce)
├── TtyParams, TerminalParams    (the allocation request)
├── TtyAdapter                   (ProtocolHandler on alk/tty — session lifecycle)
├── wire format                  (ChunkReader/ChunkWriter, ControlMessage — ADR-001)
└── negotiation framing          (self-contained ~30-line module; format coincides
                                  with alkcall's by convention — ADR-006)

alktty (local feature module — ADR-003; folded in from the old
                               alknet-tty-local sibling crate)
├── LocalTtyBackend              (impl TtyBackend — portable_pty for PTY, std::process for pipe)
├── portable_pty dependency      (PTY allocation — the heavy dep, here not in alktty default)
└── libc                         (signal forwarding — REQ-TTY-02, Unix only)

alknet-docker (or alktty-docker adapter — future crate, out of scope here)
└── DockerTtyBackend             (impl TtyBackend — wraps bollard::attach_container / exec with tty:true)

alknet-ssh (future crate — out of scope here)
└── SshTtyBackend                (impl TtyBackend — wraps russh pty_request + shell_request/exec_request)
```

alktty never sees `portable_pty` (default), `bollard`, or `russh`. The
backend implementations are opaque `Arc<dyn TtyBackend>` from the
adapter's perspective. alktty stays lean; the backend crates own their
transport dependencies. The local backend's module placement (folded
into alktty behind a `local` feature, resolving the alknet cyclic-dep
workaround) is decided in [ADR-003](decisions/003-local-backend-placement.md);
the docker and SSH backends are future crates (out of scope for this
spec set — see [tty-backend.md](tty-backend.md) §"Backend implementations"
for where they live).

## Feature Gates

```toml
# alktty Cargo.toml
[features]
default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
```

- `default` — the wire format, `TtyAdapter`, and the `TtyBackend` trait.
  No backend implementations; the assembly layer registers backends
  from their own crates. A docker-only or ssh-only deployment uses the
  default features and depends on `alknet-docker` / `alknet-ssh` (or
  their own backend crate) directly. **The default crate compiles to
  `wasm32-unknown-unknown`** — this is what makes the downstream
  TS/Python adapter story work (a wasm-compiled alktty is the protocol
  layer for a sandboxed adapter).
- `local` — enables `alktty::local::LocalTtyBackend`, pulling in
  `portable_pty` (PTY mode) and `tokio::process` (pipe mode). A consumer
  that wants the local backend (terminal or runner) enables this
  feature. Inherently non-wasm — `portable-pty` + `tokio::process` need
  a real OS.

The local backend's `portable_pty` dependency is the heavy dep that
motivates the feature gate — a docker-only deployment should not pull in
PTY allocation code. See [ADR-003](decisions/003-local-backend-placement.md).

## Architecture (component pointers)

- **[tty-wire.md]tty-wire.md** — the wire format: the negotiation
  frame (JSON carriage, self-contained length-prefixed framing), the
  raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), the
  five stream types, the control channel (split into `STREAM_CTRL_IN`
  / `STREAM_CTRL_OUT` halves, JSON control messages), sentinels, and
  the fixed-channel-set rationale.
- **[tty-bast.md]tty-bast.md** — the BAST (Binary Abstract Syntax
  Tree) document for the binary framing layer of the `alk/tty` wire
  format (the 5-byte chunk header and the negotiation frame's 4-byte
  length prefix); a normative JSON spec conforming to the BAST
  meta-schema at `https://alk.dev/bast/v1/schema`, validatable by any
  JSON Schema Draft 2020-12 validator. The JSON payloads
  (`NegotiateRequest`, `ControlMessage`, `TerminalParams`) are
  specified in `tty-wire.md` and the Rust source, not in the BAST —
  BAST describes binary layouts, not JSON shapes.
- **[tty-backend.md]tty-backend.md** — the `TtyBackend` trait,
  `TtyParams`, `TtyHandle`, `TtyControl`. The inversion point between
  the wire-format adapter and the backends. Carries REQ-TTY-01
  (backends need not be natively async; the bridging pattern is a
  documented strategy). Notes where the docker/SSH backend crates live
  (future, out of scope here).
- **[tty-adapter.md]tty-adapter.md** — the `TtyAdapter`
  (`ProtocolHandler` on `alk/tty`): the session lifecycle, the
  three-pump bidirectional driver (stdout→client, client→backend,
  exit→exit-chunk), negotiation errors, the exit-chunk ordering
  (ADR-004), access control (terminal sessions as runtime-spawned
  resources per alknet ADR-050), session-cancel cleanup (ADR-005).
- **[tty-local.md]tty-local.md** — the `local` feature module:
  `LocalTtyBackend` via `portable_pty` (PTY mode) and
  `tokio::process::Command` (pipe/runner mode). Carries REQ-TTY-02
  (signal forwarding to the foreground process group). The
  blocking→async bridge pattern (the three std threads feeding tokio
  mpsc/oneshot) is the reference for any future blocking-API backend.

## Design Decisions

| Decision | ADR | Summary |
|----------|-----|---------|
| Wire format and two-carriage model | [ADR-001]decisions/001-wire-format-and-two-carriage.md | `alk/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-4; control as JSON; Phase 7 control-channel split |
| `TtyBackend` trait and `TtyHandle` | [ADR-002]decisions/002-ttybackend-trait-and-ttyhandle.md | The backend inversion point; `exit_code` as `Future`; backends need not be natively async (REQ-TTY-01) |
| Local backend placement | [ADR-003]decisions/003-local-backend-placement.md | alktty folds the local backend in behind a `local` feature (resolves the alknet cyclic-dep workaround); PTY vs pipe per-session |
| Exit code on a control chunk | [ADR-004]decisions/004-exit-code-on-control-chunk.md | `{"type":"exit","code":N}` on `STREAM_CTRL_OUT`; "exit chunk is last" invariant; adapter owns the ordering |
| Backend cleanup on session cancel | [ADR-005]decisions/005-backend-cleanup-on-session-cancel.md | Dropping `exit_code` future (cancel) MUST kill the session target; the adapter triggers it by dropping the `TtyHandle` |
| Self-contained negotiation framing | [ADR-006]decisions/006-negotiation-framing-self-contained.md | alktty implements its own length-prefixed framing; format coincides with alkcall's by convention, not by code reuse |
| TTY inside channels (reversed) | [ADR-007]decisions/007-tty-inside-channels.md | Historical: the two-mode TTY design (direct vs inside-channels); reversed by ADR-008/093 |
| Channels pure channel multiplexing | [ADR-008]decisions/008-channels-pure-channel-multiplexing.md | TTY always uses its 5-byte format; the channels layer carries it transparently in the payload (reverses ADR-007) |
| The open op's `input` is the negotiation | [ADR-009]decisions/009-channels-open-op-is-the-negotiation.md | The channels path carries no second negotiation frame; the open op's registry-validated `input` is the negotiation |

## Open Questions

- **OQ-43** (resolved): `TtyControl` as a `Clone` trait object.
- **OQ-44** (deferred(scope)): Terminal modes (TTY modes).
- **OQ-45** (resolved): Flow control for high-throughput stdout — no
  application-level windowing; QUIC per-stream flow control is the
  backpressure mechanism.
- **OQ-46** (deferred(scope)): Runner API surface.
- **OQ-47** (resolved): Stdin closure canonical signal.

## References

- alknet ADR-001 — ALPN-based dispatch
- alknet ADR-002 — ProtocolHandler trait
- alknet ADR-003 + Amendments 1 & 2 — crate decomposition
  (no-handler-depends-on-another-handler; alktty depends on alkcall
  only; backends depend on alktty for the trait)
- alknet ADR-006 — `alk/<name>` ALPN convention; one ALPN per
  connection; new ALPN for incompatible versions
- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives-
  Connection pattern
- alknet ADR-050 — dynamic resource ownership (terminal sessions as
  runtime-spawned resources; the adapter's access-control shape
  declares against this model)
- `/workspace/@alkdev/alknet/docs/architecture/decisions/` — the alknet
  originals of the ADRs ported here as 001..008, plus the alknet ADRs
  referenced by alknet number above (which are not ported into alktty's
  ADR range because they are not tty-specific)