· 6 min read ·

Discord as a Filesystem: What Plan 9's Philosophy Does to a Modern Chat Protocol

Source: lobsters

Plan 9 from Bell Labs is a 1992 operating system built around a single idea taken further than Unix ever took it: everything is a file. Not just devices and pipes, but network connections, window state, process memory, authentication agents, and anything a process wants to export to the rest of the system. Peter Mikkelsen took that idea and applied it to Discord, building a client that exposes the entire Discord API as a synthetic 9P filesystem you navigate with cat, echo, and shell scripts.

The result is a working Discord client without a GUI, without Electron, and without any of the abstractions that modern chat clients treat as given. What makes it interesting is not the novelty of running Plan 9 software in 2026, but what the implementation reveals about the relationship between filesystem semantics and real-time protocols.

The Filesystem Layout

The client mounts itself under /mnt/discord and presents a hierarchy that mirrors Discord’s data model:

/mnt/discord/
  guilds/
    <guild-id>/
      name
      channels/
        <channel-id>/
          name
          feed
          send
          history
  dms/
    <user-id>/
      name
      feed
      send
      history

Sending a message is a write to a file. Reading the message stream is a read from feed. Fetching history is a read from history. The entire Discord API surface collapses into file operations, and you interact with it using tools that predate the web:

echo 'pushed a fix' > /mnt/discord/guilds/123456/channels/789012/send
cat /mnt/discord/guilds/123456/channels/789012/feed

The feed file is the conceptual core. It blocks on read and delivers new messages as they arrive from Discord’s Gateway, formatted as plain text. This is the same pattern Plan 9 uses throughout its own design: /dev/cons for terminal I/O, /dev/mouse for mouse events, /net/tcp/0/data for network streams. Blocking reads on files that deliver events when they occur is the Plan 9 idiom for streaming data, and Discord’s MESSAGE_CREATE events map onto it cleanly.

This is not a new idea in the Plan 9 world. The suckless IRC client ii has used a similar model for years, creating one directory per server and channel, with a FIFO named in for sending and a plain file named out for receiving. Plan 9’s approach is cleaner in one respect: 9P files support proper blocking semantics and can be mounted over the network from another machine, so the filesystem is a first-class distributed resource rather than a local FIFO arrangement.

The Engineering Problem

The interesting engineering is not the filesystem layout. That part is almost mechanical once you have lib9p, Plan 9’s standard library for building synthetic 9P servers. You implement a Srv struct with callbacks for attach, walk, open, read, write, and clunk, and the library handles the protocol framing. The hard part is everything Discord requires before you can get a message into a feed file.

Discord’s Gateway is a WebSocket connection over TLS to wss://gateway.discord.gg. Plan 9 has none of the pieces a modern OS developer would reach for here: no WebSocket library, no high-level TLS wrapper in the standard sense, no JSON parser in libc. Each layer has to be built from Plan 9 primitives.

TCP in Plan 9 is done through the filesystem itself. You open /net/tcp/clone to allocate a connection slot, read the connection number, then write connect gateway.discord.gg!443 to the corresponding ctl file. The data file gives you a raw byte stream. This is the file-as-network-connection model in practice, and it works cleanly as a foundation.

TLS comes from libsec, Plan 9’s cryptography library. The tlsClient() function wraps a raw TCP file descriptor and returns a new descriptor for the encrypted channel. Plan 9’s TLS support covers through TLS 1.2 in the original Bell Labs release; 9front, the actively maintained fork that most real Plan 9 users run today, has added TLS 1.3 support, which matters for connecting to services like Discord that require modern cipher suites.

WebSocket is where the implementation gets granular. RFC 6455 requires an HTTP/1.1 upgrade handshake before the WebSocket framing begins. The handshake involves generating a random 16-byte key, base64-encoding it, sending it in a Sec-WebSocket-Key header, and validating the server’s Sec-WebSocket-Accept response by computing base64(SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")). The SHA1 and base64 primitives are available in libsec, so this is tedious but tractable.

After the handshake, every frame requires parsing the 2-to-14 byte header that specifies the opcode, payload length, and masking flag. Client-to-server frames must be masked with a 4-byte XOR key. Server-to-client frames must not be masked. Fragmented messages, ping/pong frames, and close frames all need handling. This is several hundred lines of careful C before you can send or receive a single JSON payload.

JSON has no standard library support on Plan 9. The most practical approach for a project like this is a minimal recursive-descent parser or a single-file library like jsmn, adapted for Plan 9 C. Discord’s Gateway payloads are complex enough, with nested objects and arrays, that a full parser matters.

Concurrency and the Deferred Read Problem

The 9P server needs to handle three concurrent concerns: the Gateway WebSocket receive loop, periodic heartbeats (Discord requires a HEARTBEAT opcode every heartbeat_interval milliseconds or the connection closes), and incoming 9P requests from local processes reading feed files.

Plan 9 addresses this with libthread, a cooperative threading library with CSP-style channels that is a direct ancestor of Go’s concurrency model. The Gateway loop runs in one thread, delivering messages to a channel. The heartbeat timer runs in another. The 9P server thread handles Tread requests from local clients.

The blocking feed read requires a technique lib9p supports: deferred responses. When a process opens feed and calls read, the 9P server receives a Tread request. Instead of responding immediately, the server stores the Req* pointer and holds it until the Gateway thread delivers a new message. At that point, the server calls respond() on the stored request, the data flows to the reading process, and the file appears to have blocked naturally. This is how Plan 9’s own kernel implements blocking reads on device files like /dev/cons, and lib9p exposes the same mechanism to userspace 9P servers.

What the Mapping Reveals

Discord’s design is event-driven at its core. The Gateway pushes MESSAGE_CREATE, TYPING_START, PRESENCE_UPDATE, and dozens of other events over a persistent WebSocket. The REST API handles stateless operations: sending messages, fetching history, managing channel state. These two layers map cleanly onto Plan 9’s file model: the Gateway’s event stream becomes a blocking read on feed, and the REST calls become writes to send and reads from history.

The fit is not accidental. Streaming event sources and file reads that block until data arrives are the same abstraction seen from different angles. Plan 9 recognized this in 1992. The same observation underlies the ii IRC client, Linux’s own /dev/input event devices, and the inotify interface. Real-time protocols do not require special-purpose client architectures; they require the right blocking semantics on a data source, and files can provide those semantics.

What Plan 9 adds beyond what a FIFO-based approach like ii provides is network transparency. A 9P filesystem can be mounted over the network just as easily as locally. In principle, you could run the Discord 9P server on one Plan 9 machine and mount it on another, reading messages and sending replies over the local network without any Discord-specific networking on the consuming machine. The chat client becomes an infrastructure service in the same way a database or file server is.

The implementation requires solving real problems: WebSocket framing, TLS with SNI, JSON parsing, concurrent 9P serving with deferred responses. None of these are insurmountable, but none are trivial in Plan 9 C without the ecosystem of libraries a Go or Python developer would reach for. The project is a useful demonstration that the hard part of Plan 9’s philosophy is not the concept but the integration surface with a world that has moved on to different assumptions about what an operating system provides.

Was this interesting?