Type in Zed, see it live on your blog. Every word you type streams to your Phoenix app in real time via a persistent WebSocket channel.
Zed editor Your blog
| |
| (type a word + space) |
| textDocument/didChange |
+---> LiveDraftLSP ----WebSocket--> Phoenix Channel
(Elixir) (persistent) |
v
open .md file ---> join channel PubSub broadcast
switch files ---> rejoin |
type + space ---> push content v
Cmd+S ---> push content LiveView re-renders
with pulsing LIVE badge
Three pieces work together:
- Phoenix Channel (your blog) —
LiveDraftChannelreceives markdown over a persistent WebSocket, renders it, broadcasts via PubSub, LiveView updates in real time - LiveDraftLSP (this repo) — Elixir LSP server that connects a WebSocket to your blog on startup, joins a channel per post, and pushes content on word boundaries
- zed-live-draft — thin Rust/WASM Zed extension that launches the LSP for Markdown files
The key insight: one persistent WebSocket connection handles everything. No HTTP request per word. The LSP opens the socket once when Zed starts, joins a channel when you open a markdown file, and pushes content as you type. If you switch to a different post file, it re-joins for the new slug.
- Elixir >= 1.14 installed locally
- A Phoenix blog with the live-draft channel deployed (see Server Setup)
- Zed editor
git clone https://github.com/notactuallytreyanastasio/live_draft_lsp.git
cd live_draft_lsp
mix deps.get
mix escript.build
cp live_draft_lsp ~/.local/bin/Make sure ~/.local/bin is on your PATH. Add to your shell profile if needed:
export PATH="$HOME/.local/bin:$PATH"Verify it's found:
which live_draft_lspClone the extension:
git clone https://github.com/notactuallytreyanastasio/zed-live-draft.gitIn Zed:
- Open Command Palette (Cmd+Shift+P)
- Type
zed: install dev extension - Select the
zed-live-draftfolder you just cloned
The extension registers a language server for Markdown files. When you open any .md file, Zed will launch live_draft_lsp in the background.
Create a .live-draft.json in the root of your blog repo:
{
"url": "https://yourblog.com/api/live-draft",
"token": "your-secret-token-here"
}For local development:
{
"url": "http://localhost:4000/api/live-draft",
"token": "dev-live-draft-token"
}The url field is used to derive the WebSocket URL. The LSP converts https://yourblog.com/... to wss://yourblog.com/socket/websocket?token=... automatically.
Add .live-draft.json to your .gitignore (it contains your auth token).
Alternatively, use environment variables instead of the config file:
export LIVE_DRAFT_URL="https://yourblog.com/api/live-draft"
export LIVE_DRAFT_TOKEN="your-secret-token-here"- Start your Phoenix blog (
mix phx.server) - Open a post markdown file in Zed (e.g.
priv/static/posts/2026-02-09-00-00-00-my-post.md) - Open
http://localhost:4000/post/my-postin a browser - Start typing — every time you hit space, period, or enter, the page updates live
- A pulsing red LIVE badge appears in the title bar while streaming
On LSP startup, the SocketClient opens a persistent WebSocket to your Phoenix app's /socket/websocket endpoint with your auth token.
When you open a markdown file, the LSP joins a Phoenix Channel topic live_draft:<slug>. When you switch to a different markdown file, it joins the new channel.
On every keystroke, Zed sends the full document to the LSP via textDocument/didChange. The LSP checks if the text ends with a word boundary (space, period, newline). If so, it pushes the content over the channel with draft:update.
On save (Cmd+S), it always pushes regardless of the last character, as a guaranteed sync point.
On the server, the LiveDraftChannel:
- Authenticates on join via token
- On
draft:update, passes content toBlog.LiveDraft Blog.LiveDraftrenders the markdown with Earmark, stores in ETS, broadcasts via PubSub
In the browser, the PostLive LiveView:
- Subscribes to
live_draft:#{slug}PubSub topic on mount - Replaces the post HTML when a broadcast arrives
- Shows a pulsing LIVE badge
- Reverts to the static file content after 2 minutes of inactivity
Reconnection: If the WebSocket drops, WebSockEx automatically reconnects. The channel re-join happens on the next keystroke or file open.
Your Phoenix blog needs these additions. All changes are in the blog repo.
lib/blog/live_draft.ex — GenServer with ETS cache:
defmodule Blog.LiveDraft do
use GenServer
@table :live_drafts
def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
def init(_) do
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
end
{:ok, %{}}
end
def update(slug, content) do
html = render_markdown(content)
now = DateTime.utc_now()
:ets.insert(@table, {slug, content, html, now})
Phoenix.PubSub.broadcast!(Blog.PubSub, "live_draft:#{slug}", {:live_draft_update, slug, html, now})
{:ok, html}
end
def get(slug) do
case :ets.lookup(@table, slug) do
[{^slug, _, html, at}] ->
if DateTime.diff(DateTime.utc_now(), at) < 120, do: {:ok, html, at}, else: :stale
[] -> :none
end
end
endlib/blog_web/channels/live_draft_channel.ex — Phoenix Channel:
defmodule BlogWeb.LiveDraftChannel do
use Phoenix.Channel
def join("live_draft:" <> slug, %{"token" => token}, socket) do
expected = Application.get_env(:blog, :live_draft_api_token)
if token == expected && token != nil do
{:ok, assign(socket, :slug, slug)}
else
{:error, %{reason: "unauthorized"}}
end
end
def handle_in("draft:update", %{"content" => content}, socket) do
{:ok, _} = Blog.LiveDraft.update(socket.assigns.slug, content)
{:reply, :ok, socket}
end
endlib/blog_web/channels/user_socket.ex — add channel route:
channel "live_draft:*", BlogWeb.LiveDraftChannellib/blog/application.ex — add Blog.LiveDraft to children (after PubSub)
lib/blog_web/router.ex — add HTTP route in the /api scope (kept as fallback):
post "/live-draft", LiveDraftController, :updateconfig/runtime.exs — add inside the prod block:
config :blog, :live_draft_api_token, System.get_env("LIVE_DRAFT_TOKEN")config/config.exs — add dev default:
config :blog, :live_draft_api_token, "dev-live-draft-token"lib/blog_web/live/post_live.ex — subscribe to PubSub topic, handle updates:
# In mount, inside if connected?(socket):
Phoenix.PubSub.subscribe(Blog.PubSub, "live_draft:#{slug}")
# New handle_info clauses:
def handle_info({:live_draft_update, _slug, html, _at}, socket) do
{:noreply, assign(socket, html: html, live_draft_active: true)}
endSet the secret on your hosting provider:
# Fly.io
fly secrets set LIVE_DRAFT_TOKEN="$(openssl rand -hex 32)"
# Gigalixir
gigalixir config:set LIVE_DRAFT_TOKEN="$(openssl rand -hex 32)"Use the same token value in your local .live-draft.json.
The LSP derives the post slug from the filename. It expects the blog naming pattern:
YYYY-MM-DD-HH-MM-SS-slug-words-here.md
The slug is everything after the timestamp. For example:
| Filename | Slug |
|---|---|
2026-02-09-00-00-00-my-first-post.md |
my-first-post |
2025-12-25-14-30-00-building-this-blog.md |
building-this-blog |
random-notes.md |
random-notes (fallback: full basename) |
LSP not starting in Zed?
- Check
which live_draft_lspreturns a path - Open Zed's log (Cmd+Shift+P > "zed: open log") and search for "live-draft"
Posts not updating?
- Verify
.live-draft.jsonexists in your project root with the correct URL and token - Check the Phoenix server logs for
[LiveDraft] Author joined channelmessages - Make sure the slug in the URL matches an existing post (the post must be in
@allowed_slugs)
WebSocket not connecting?
- The LSP converts your
urlconfig to a WebSocket URL:https://x.com/api/...becomeswss://x.com/socket/websocket?token=... - Check that
/socket/websocketis accessible on your blog (it uses the existingUserSocket) - If behind a reverse proxy, ensure WebSocket upgrade headers are forwarded
LIVE badge not appearing?
- The badge only shows when a
live_draft_updatePubSub message arrives - Open the browser's network tab to confirm WebSocket messages are arriving
- The badge disappears after 2 minutes of inactivity (staleness timeout)
MIT