diff --git a/Justfile b/Justfile
index 2ec5a77973..a271a6ebd8 100644
--- a/Justfile
+++ b/Justfile
@@ -38,7 +38,7 @@ dev *args:
   watchexec -r {{WATCHEXEC_ARGS}} -- "just build-sqlc && ftl dev {{args}}"
 
 # Build everything
-build-all: build-protos-unconditionally build-frontend build-generate build-sqlc build-zips
+build-all: build-protos-unconditionally build-frontend build-generate build-sqlc build-zips lsp-generate
   @just build ftl ftl-controller ftl-runner ftl-initdb
 
 # Run "go generate" on all packages
@@ -144,4 +144,8 @@ lint-backend:
 # Run live docs server
 docs:
   git submodule update --init --recursive
-  cd docs && zola serve
\ No newline at end of file
+  cd docs && zola serve
+
+# Generate LSP hover help text
+lsp-generate:
+  @mk lsp/hoveritems.go : lsp docs/content -- "scripts/ftl-gen-lsp"
diff --git a/cmd/ftl-gen-lsp/main.go b/cmd/ftl-gen-lsp/main.go
new file mode 100644
index 0000000000..12c60bbbec
--- /dev/null
+++ b/cmd/ftl-gen-lsp/main.go
@@ -0,0 +1,204 @@
+// This program generates hover items for the FTL LSP server.
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+
+	"github.com/alecthomas/kong"
+)
+
+type CLI struct {
+	Config  string `type:"filepath" default:"lsp/hover.json" help:"Path to the hover configuration file"`
+	DocRoot string `type:"dirpath" default:"docs/content/docs" help:"Path to the config referenced markdowns"`
+	Output  string `type:"filepath" default:"lsp/hoveritems.go" help:"Path to the generated Go file"`
+}
+
+var cli CLI
+
+type hover struct {
+	// Match this text for triggering this hover, e.g. "//ftl:typealias"
+	Match string `json:"match"`
+
+	// Source file to read from.
+	Source string `json:"source"`
+
+	// Select these heading to use for the docs. If omitted, the entire markdown file is used.
+	// Headings are included in the output.
+	Select []string `json:"select,omitempty"`
+}
+
+func main() {
+	kctx := kong.Parse(&cli,
+		kong.Description(`Generate hover items for FTL LSP. See lsp/hover.go`),
+	)
+
+	hovers, err := parseHoverConfig(cli.Config)
+	kctx.FatalIfErrorf(err)
+
+	items, err := scrapeDocs(hovers)
+	kctx.FatalIfErrorf(err)
+
+	err = writeGoFile(cli.Output, items)
+	kctx.FatalIfErrorf(err)
+}
+
+func scrapeDocs(hovers []hover) (map[string]string, error) {
+	items := make(map[string]string, len(hovers))
+	for _, hover := range hovers {
+		path := filepath.Join(cli.DocRoot, hover.Source)
+		file, err := os.Open(path)
+		if err != nil {
+			return nil, fmt.Errorf("failed to open %s: %w", path, err)
+		}
+
+		doc, err := getMarkdownWithTitle(file)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read %s: %w", path, err)
+		}
+
+		var content string
+		if len(hover.Select) > 0 {
+			for _, sel := range hover.Select {
+				chunk, err := selector(doc.Content, sel)
+				if err != nil {
+					return nil, fmt.Errorf("failed to select %s from %s: %w", sel, path, err)
+				}
+				content += chunk
+			}
+		} else {
+			// We need to inject a heading for the hover content because the full content doesn't always have a heading.
+			content = fmt.Sprintf("## %s%s", doc.Title, doc.Content)
+		}
+
+		items[hover.Match] = content
+	}
+	return items, nil
+}
+
+func parseHoverConfig(path string) ([]hover, error) {
+	file, err := os.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("failed to open %s: %w", path, err)
+	}
+
+	var hovers []hover
+	err = json.NewDecoder(file).Decode(&hovers)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse JSON %s: %w", path, err)
+	}
+
+	return hovers, nil
+}
+
+type Doc struct {
+	Title   string
+	Content string
+}
+
+// getMarkdownWithTitle reads a Zola markdown file and returns the full markdown content and the title.
+func getMarkdownWithTitle(file *os.File) (*Doc, error) {
+	content, err := io.ReadAll(file)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read %s: %w", file.Name(), err)
+	}
+
+	// Zola markdown files have a +++ delimiter. An initial one, then metadata, then markdown content.
+	parts := bytes.Split(content, []byte("+++"))
+	if len(parts) < 3 {
+		return nil, fmt.Errorf("file %s does not contain two +++ strings", file.Name())
+	}
+
+	// Look for the title in the metadata.
+	// title = "PubSub"
+	title := ""
+	lines := strings.Split(string(parts[1]), "\n")
+	for _, line := range lines {
+		if strings.HasPrefix(line, "title = ") {
+			title = strings.TrimSpace(strings.TrimPrefix(line, "title = "))
+			title = strings.Trim(title, "\"")
+			break
+		}
+	}
+	if title == "" {
+		return nil, fmt.Errorf("file %s does not contain a title", file.Name())
+	}
+
+	return &Doc{Title: title, Content: string(parts[2])}, nil
+}
+
+func selector(content, selector string) (string, error) {
+	// Split the content into lines.
+	lines := strings.Split(content, "\n")
+	collected := []string{}
+
+	// If the selector starts with ## (the only type of heading we have):
+	// Find the line, include it, and all lines until the next heading.
+	if !strings.HasPrefix(selector, "##") {
+		return "", fmt.Errorf("unsupported selector %s", selector)
+	}
+	include := false
+	for _, line := range lines {
+		if include {
+			// We have found another heading. Abort!
+			if strings.HasPrefix(line, "##") {
+				break
+			}
+
+			// We also stop at a line break, because we don't want to include footnotes.
+			// See the end of docs/content/docs/reference/types.md for an example.
+			if line == "---" {
+				break
+			}
+
+			collected = append(collected, line)
+		}
+
+		// Start collecting
+		if strings.HasPrefix(line, selector) {
+			include = true
+			collected = append(collected, line)
+		}
+	}
+
+	if len(collected) == 0 {
+		return "", fmt.Errorf("no content found for selector %s", selector)
+	}
+
+	return strings.TrimSpace(strings.Join(collected, "\n")) + "\n", nil
+}
+
+func writeGoFile(path string, items map[string]string) error {
+	file, err := os.Create(path)
+	if err != nil {
+		return fmt.Errorf("failed to create %s: %w", path, err)
+	}
+	defer file.Close()
+
+	tmpl, err := template.New("").Parse(`// Code generated by 'just lsp-generate'. DO NOT EDIT.
+package lsp
+
+var hoverMap = map[string]string{
+{{- range $match, $content := . }}
+	{{ printf "%q" $match }}: {{ printf "%q" $content }},
+{{- end }}
+}
+`)
+	if err != nil {
+		return fmt.Errorf("failed to parse template: %w", err)
+	}
+
+	err = tmpl.Execute(file, items)
+	if err != nil {
+		return fmt.Errorf("failed to execute template: %w", err)
+	}
+
+	fmt.Printf("Generated %s\n", path)
+	return nil
+}
diff --git a/docs/content/docs/reference/types.md b/docs/content/docs/reference/types.md
index 7a811b1bdf..c1026afe41 100644
--- a/docs/content/docs/reference/types.md
+++ b/docs/content/docs/reference/types.md
@@ -75,4 +75,6 @@ eg.
 type UserID string
 ```
 
+---
+
 [^1]: Note that until [type widening](https://github.com/TBD54566975/ftl/issues/1296) is implemented, external types are not supported.
diff --git a/lsp/hover.go b/lsp/hover.go
index d2b22ddd13..cdecb26e57 100644
--- a/lsp/hover.go
+++ b/lsp/hover.go
@@ -8,17 +8,6 @@ import (
 	protocol "github.com/tliron/glsp/protocol_3_16"
 )
 
-//go:embed markdown/hover/verb.md
-var verbHoverContent string
-
-//go:embed markdown/hover/enum.md
-var enumHoverContent string
-
-var hoverMap = map[string]string{
-	"//ftl:verb": verbHoverContent,
-	"//ftl:enum": enumHoverContent,
-}
-
 func (s *Server) textDocumentHover() protocol.TextDocumentHoverFunc {
 	return func(context *glsp.Context, params *protocol.HoverParams) (*protocol.Hover, error) {
 		uri := params.TextDocument.URI
diff --git a/lsp/hover.json b/lsp/hover.json
new file mode 100644
index 0000000000..97ee6361f5
--- /dev/null
+++ b/lsp/hover.json
@@ -0,0 +1,32 @@
+[
+  {
+    "match": "//ftl:verb",
+    "source": "reference/verbs.md"
+  },
+  {
+    "match": "//ftl:typealias",
+    "source": "reference/types.md",
+    "select": ["## Type aliases"]
+  },
+  {
+    "match": "//ftl:enum",
+    "source": "reference/types.md",
+    "select": ["## Type enums (sum types)", "## Value enums"]
+  },
+  {
+    "match": "//ftl:cron",
+    "source": "reference/cron.md"
+  },
+  {
+    "match": "//ftl:ingress",
+    "source": "reference/ingress.md"
+  },
+  {
+    "match": "//ftl:subscribe",
+    "source": "reference/pubsub.md"
+  },
+  {
+    "match": "//ftl:retry",
+    "source": "reference/retries.md"
+  }
+]
\ No newline at end of file
diff --git a/lsp/hoveritems.go b/lsp/hoveritems.go
new file mode 100644
index 0000000000..94a9fd1d17
--- /dev/null
+++ b/lsp/hoveritems.go
@@ -0,0 +1,12 @@
+// Code generated by 'just lsp-generate'. DO NOT EDIT.
+package lsp
+
+var hoverMap = map[string]string{
+	"//ftl:cron": "## Cron\n\nA cron job is an Empty verb that will be called on a schedule. The syntax is described [here](https://pubs.opengroup.org/onlinepubs/9699919799.2018edition/utilities/crontab.html).\n\nYou can also use a shorthand syntax for the cron job, supporting seconds (`s`), minutes (`m`), hours (`h`), and specific days of the week (e.g. `Mon`).\n\n### Examples\n\nThe following function will be called hourly:\n\n```go\n//ftl:cron 0 * * * *\nfunc Hourly(ctx context.Context) error {\n  // ...\n}\n```\n\nEvery 12 hours, starting at UTC midnight:\n\n```go\n//ftl:cron 12h\nfunc TwiceADay(ctx context.Context) error {\n  // ...\n}\n```\n\nEvery Monday at UTC midnight:\n\n```go\n//ftl:cron Mon\nfunc Mondays(ctx context.Context) error {\n  // ...\n}\n```\n\n",
+	"//ftl:enum": "## Type enums (sum types)\n\n[Sum types](https://en.wikipedia.org/wiki/Tagged_union) are supported by FTL's type system, but aren't directly supported by Go. However they can be approximated with the use of [sealed interfaces](https://blog.chewxy.com/2018/03/18/golang-interfaces/). To declare a sum type in FTL use the comment directive `//ftl:enum`:\n\n```go\n//ftl:enum\ntype Animal interface { animal() }\n\ntype Cat struct {}\nfunc (Cat) animal() {}\n\ntype Dog struct {}\nfunc (Dog) animal() {}\n```\n## Value enums\n\nA value enum is an enumerated set of string or integer values.\n\n```go\n//ftl:enum\ntype Colour string\n\nconst (\n  Red   Colour = \"red\"\n  Green Colour = \"green\"\n  Blue  Colour = \"blue\"\n)\n```\n",
+	"//ftl:ingress": "## HTTP Ingress\n\nVerbs annotated with `ftl:ingress` will be exposed via HTTP (`http` is the default ingress type). These endpoints will then be available on one of our default `ingress` ports (local development defaults to `http://localhost:8891`).\n\nThe following will be available at `http://localhost:8891/http/users/123/posts?postId=456`.\n\n```go\ntype GetRequest struct {\n\tUserID string `json:\"userId\"`\n\tPostID string `json:\"postId\"`\n}\n\ntype GetResponse struct {\n\tMessage string `json:\"msg\"`\n}\n\n//ftl:ingress GET /http/users/{userId}/posts\nfunc Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, ErrorResponse], error) {\n  // ...\n}\n```\n\n> **NOTE!**\n> The `req` and `resp` types of HTTP `ingress` [verbs](../verbs) must be `builtin.HttpRequest` and `builtin.HttpResponse` respectively. These types provide the necessary fields for HTTP `ingress` (`headers`, `statusCode`, etc.)\n> \n> You will need to import `ftl/builtin`.\n\nKey points to note\n\n- `path`, `query`, and `body` parameters are automatically mapped to the `req` and `resp` structures. In the example above, `{userId}` is extracted from the path parameter and `postId` is extracted from the query parameter.\n- `ingress` verbs will be automatically exported by default.\n",
+	"//ftl:retry": "## Retries\n\nAny verb called asynchronously (specifically, PubSub subscribers and FSM states), may optionally specify a basic exponential backoff retry policy via a Go comment directive. The directive has the following syntax:\n\n```go\n//ftl:retry [<attempts>] <min-backoff> [<max-backoff>]\n```\n\n`attempts` and `max-backoff` default to unlimited if not specified.\n\nFor example, the following function will retry up to 10 times, with a delay of 5s, 10s, 20s, 40s, 60s, 60s, etc.\n\n```go\n//ftl:retry 10 5s 1m\nfunc Invoiced(ctx context.Context, in Invoice) error {\n  // ...\n}\n```\n",
+	"//ftl:subscribe": "## PubSub\n\nFTL has first-class support for PubSub, modelled on the concepts of topics (where events are sent), subscriptions (a cursor over the topic), and subscribers (functions events are delivered to). Subscribers are, as you would expect, sinks. Each subscription is a cursor over the topic it is associated with. Each topic may have multiple subscriptions. Each subscription may have multiple subscribers, in which case events will be distributed among them.\n\nFirst, declare a new topic:\n\n```go\nvar invoicesTopic = ftl.Topic[Invoice](\"invoices\")\n```\n\nThen declare each subscription on the topic:\n\n```go\nvar _ = ftl.Subscription(invoicesTopic, \"emailInvoices\")\n```\n\nAnd finally define a Sink to consume from the subscription:\n\n```go\n//ftl:subscribe emailInvoices\nfunc SendInvoiceEmail(ctx context.Context, in Invoice) error {\n  // ...\n}\n```\n\nEvents can be published to a topic like so:\n\n```go\ninvoicesTopic.Publish(ctx, Invoice{...})\n```\n\n> **NOTE!**\n> PubSub topics cannot be published to from outside the module that declared them, they can only be subscribed to. That is, if a topic is declared in module `A`, module `B` cannot publish to it.\n",
+	"//ftl:typealias": "## Type aliases\n\nA type alias is an alternate name for an existing type. It can be declared like so:\n\n```go\n//ftl:typealias\ntype Alias Target\n```\n\neg.\n\n```go\n//ftl:typealias\ntype UserID string\n```\n",
+	"//ftl:verb": "## Verbs\n\n## Defining Verbs\n\nTo declare a Verb, write a normal Go function with the following signature, annotated with the Go [comment directive](https://tip.golang.org/doc/comment#syntax) `//ftl:verb`:\n\n```go\n//ftl:verb\nfunc F(context.Context, In) (Out, error) { }\n```\n\neg.\n\n```go\ntype EchoRequest struct {}\n\ntype EchoResponse struct {}\n\n//ftl:verb\nfunc Echo(ctx context.Context, in EchoRequest) (EchoResponse, error) {\n  // ...\n}\n```\n\nBy default verbs are only [visible](../visibility) to other verbs in the same module.\n\n## Calling Verbs\n\nTo call a verb use `ftl.Call()`. eg.\n\n```go\nout, err := ftl.Call(ctx, echo.Echo, echo.EchoRequest{})\n```\n",
+}
diff --git a/lsp/markdown/hover/enum.md b/lsp/markdown/hover/enum.md
deleted file mode 100644
index 0262bf23f7..0000000000
--- a/lsp/markdown/hover/enum.md
+++ /dev/null
@@ -1,31 +0,0 @@
-## Type enums (sum types)
-
-[Sum types](https://en.wikipedia.org/wiki/Tagged_union) are supported by FTL's type system, but aren't directly supported by Go. However they can be approximated with the use of [sealed interfaces](https://blog.chewxy.com/2018/03/18/golang-interfaces/). To declare a sum type in FTL use the comment directive `//ftl:enum`:
-
-```go
-//ftl:enum
-type Animal interface { animal() }
-
-type Cat struct {}
-func (Cat) animal() {}
-
-type Dog struct {}
-func (Dog) animal() {}
-```
-
-## Value enums
-
-A value enum is an enumerated set of string or integer values.
-
-```go
-//ftl:enum
-type Colour string
-
-const (
-  Red   Colour = "red"
-  Green Colour = "green"
-  Blue  Colour = "blue"
-)
-```
-
-[Reference](https://tbd54566975.github.io/ftl/docs/reference/types/)
diff --git a/lsp/markdown/hover/verb.md b/lsp/markdown/hover/verb.md
deleted file mode 100644
index 7d8e9d0f27..0000000000
--- a/lsp/markdown/hover/verb.md
+++ /dev/null
@@ -1,19 +0,0 @@
-## Verb
-
-Verbs are the function primitives of FTL. They take a single value and return a single value or an error.
-
-`F(X) -> Y`
-
-eg.
-
-```go
-type EchoRequest struct {}
-type EchoResponse struct {}
-
-//ftl:verb
-func Echo(ctx context.Context, in EchoRequest) (EchoResponse, error) {
-  // ...
-}
-```
-
-[Reference](https://tbd54566975.github.io/ftl/docs/reference/verbs/)
diff --git a/scripts/ftl-gen-lsp b/scripts/ftl-gen-lsp
new file mode 120000
index 0000000000..1db38bf169
--- /dev/null
+++ b/scripts/ftl-gen-lsp
@@ -0,0 +1 @@
+ftl
\ No newline at end of file