diff --git a/cli/commands.go b/cli/commands.go
index 6cf9013..8c7a818 100644
--- a/cli/commands.go
+++ b/cli/commands.go
@@ -16,6 +16,7 @@ func registerEscapeHatches(app *kit.App) {
 	app.AddCommand(newDownloadCmd())
 	app.AddCommand(newExtractCmd())
 	app.AddCommand(newMusicCmd())
+	app.AddCommand(newDiscoverCmd())
 	app.AddCommand(newSeedCmd())
 	app.AddCommand(newCrawlCmd())
 	app.AddCommand(newQueueCmd())
diff --git a/cli/discover.go b/cli/discover.go
new file mode 100644
index 0000000..5e1beba
--- /dev/null
+++ b/cli/discover.go
@@ -0,0 +1,145 @@
+package cli
+
+import (
+	"context"
+
+	"github.com/tamnd/any-cli/kit"
+	"github.com/tamnd/ytb-cli/youtube"
+)
+
+// defaultDiscoverBudget caps a streaming walk when the user did not pass -n, so
+// `ytb discover <video>` always terminates instead of spidering YouTube forever.
+const defaultDiscoverBudget = 500
+
+// newDiscoverCmd is the breadth-first graph walk. Where the record reads each
+// answer one question about one object, discover chains them: from a seed video,
+// channel, or playlist it follows the object's links and from each neighbor it
+// follows theirs, hop by hop, emitting one row per node as it is reached.
+//
+// It shares the read group with the per-object commands because it is a read; it
+// only touches the store when --store is set, where it persists each node and
+// records every traversed edge into the edges table.
+func newDiscoverCmd() kit.Command {
+	var (
+		depth  int
+		fanout int
+		follow string
+		store  bool
+	)
+	return kit.Command{
+		Use:     "discover <seed>...",
+		Aliases: []string{"walk", "graph"},
+		Group:   "read",
+		Short:   "Breadth-first walk of the graph linked from a video, channel, or playlist",
+		Long: `Walk the graph of linked YouTube objects, breadth first, starting from one or
+more seeds. A seed is anything ytb can resolve: a video id or watch URL, a
+channel id, @handle, or URL, or a playlist id or URL.
+
+--follow chooses which links to traverse. It takes a preset or a comma-separated
+edge list:
+
+  content   a video's channel and related videos, a channel's uploads, a
+            playlist's items and owner (the default; the obvious neighbors)
+  feed      a channel's uploads, playlists, and community posts
+  comments  a video's comments and the channels that wrote them
+  all       every edge
+
+Edges: channel, related, comments, uploads, playlists, community, items, owner,
+commenter. Name an edge directly to follow just that link, e.g. --follow related
+to chase the recommendation graph.
+
+--depth is how many hops to follow (default 1; 0 emits only the seeds). --fanout
+caps neighbors per edge (default 25). The walk streams nodes and stops after -n
+nodes (default 500). Comments are served only when YouTube is not applying its
+per-IP Restricted Mode to this network; when it is, the comment edges are noted
+and skipped and the rest of the walk continues.
+
+Add --store to persist every node into its typed table and record each traversed
+edge into the edges table, so a walk doubles as a crawl. Query it afterwards with
+ytb db query.`,
+		Args: kit.MinimumNArgs(1),
+		Flags: func(f *kit.FlagSet) {
+			f.IntVar(&depth, "depth", 1, "hops to follow from each seed (0 = seeds only)")
+			f.IntVar(&fanout, "fanout", 25, "max neighbors to follow per edge (0 = unlimited)")
+			f.StringVar(&follow, "follow", "content", "edges to follow ("+youtube.EdgeHelp()+")")
+			f.BoolVar(&store, "store", false, "persist nodes and edges into the local store")
+		},
+		Run: func(ctx context.Context, args []string) error {
+			app := appFromCtx(ctx)
+
+			edges, err := youtube.ParseEdges(follow)
+			if err != nil {
+				return usageErr(err.Error())
+			}
+			seeds, err := parseSeeds(args)
+			if err != nil {
+				return err
+			}
+
+			var st *youtube.Store
+			if store {
+				st, err = app.RequireStore()
+				if err != nil {
+					return err
+				}
+			}
+
+			budget := app.Limit
+			if budget <= 0 {
+				budget = defaultDiscoverBudget
+			}
+
+			opts := youtube.WalkOptions{
+				Depth:  depth,
+				Max:    budget,
+				Fanout: fanout,
+				Edges:  edges,
+				Note:   func(s string) { app.logf("note: %s", s) },
+			}
+			if st != nil {
+				opts.OnEdge = func(src, dst string, e youtube.Edge) {
+					_ = st.UpsertEdge(src, dst, string(e))
+				}
+			}
+
+			n := 0
+			err = app.Client.Walk(ctx, seeds, opts, func(nd *youtube.Node) error {
+				if st != nil {
+					_ = st.UpsertNode(nd)
+				}
+				if e := app.Out.Emit(nodeRow(nd)); e != nil {
+					return e
+				}
+				n++
+				return nil
+			})
+			if flushErr := app.Out.Flush(); flushErr != nil && err == nil {
+				err = flushErr
+			}
+			if err != nil {
+				// The only fatal walk error is a seed that could not be fetched;
+				// it surfaces like a failed single read. Deeper failures (a gated
+				// comment edge, a missing community tab) are notes, not errors.
+				return err
+			}
+			if n == 0 {
+				return noResults("nothing discovered")
+			}
+			return nil
+		},
+	}
+}
+
+// parseSeeds turns the positional arguments into walk seeds, reporting an
+// unrecognized reference as a usage error rather than a plain failure.
+func parseSeeds(args []string) ([]youtube.Seed, error) {
+	seeds := make([]youtube.Seed, 0, len(args))
+	for _, a := range args {
+		s, err := youtube.ParseSeed(a)
+		if err != nil {
+			return nil, usageErr(err.Error())
+		}
+		seeds = append(seeds, s)
+	}
+	return seeds, nil
+}
diff --git a/cli/rows.go b/cli/rows.go
index 78ea39e..ef2e3c1 100644
--- a/cli/rows.go
+++ b/cli/rows.go
@@ -2,6 +2,7 @@ package cli
 
 import (
 	"strconv"
+	"strings"
 
 	"github.com/tamnd/ytb-cli/youtube"
 )
@@ -10,6 +11,65 @@ func itoa(n int) string   { return strconv.Itoa(n) }
 func i64a(n int64) string { return strconv.FormatInt(n, 10) }
 func boola(b bool) string { return strconv.FormatBool(b) }
 
+// oneline flattens a value to a single short line for a curated column, so a
+// multi-line title or comment body never breaks the list and table layouts.
+func oneline(s string) string {
+	s = strings.Join(strings.Fields(s), " ")
+	if len(s) > 80 {
+		return s[:79] + "..."
+	}
+	return s
+}
+
+// nodeRow renders a graph node discovered by `ytb discover`. The curated columns
+// read the walk at a glance (how deep, by which edge, and a one-line who/what)
+// while the full typed node (with the nested video, channel, playlist, comment,
+// or post) rides in Value for json, jsonl, and templates.
+func nodeRow(n *youtube.Node) Row {
+	var id, who, summary, url string
+	switch n.Kind {
+	case youtube.KindVideo:
+		v := n.Video
+		id = v.VideoID
+		who = v.ChannelName
+		summary = oneline(v.Title)
+		url = v.URL
+		if url == "" {
+			url = "https://www.youtube.com/watch?v=" + v.VideoID
+		}
+	case youtube.KindChannel:
+		c := n.Channel
+		id = c.ChannelID
+		who = c.Handle
+		if who == "" {
+			who = c.Title
+		}
+		summary = oneline(c.Title)
+		url = c.URL
+	case youtube.KindPlaylist:
+		p := n.Playlist
+		id = p.PlaylistID
+		who = p.ChannelName
+		summary = oneline(p.Title)
+		url = p.URL
+	case youtube.KindComment:
+		c := n.Comment
+		id = c.ID
+		who = c.AuthorDisplayName
+		summary = oneline(c.TextDisplay)
+	case youtube.KindPost:
+		p := n.Post
+		id = p.PostID
+		who = p.AuthorName
+		summary = oneline(p.ContentText)
+	}
+	return Row{
+		Cols:  []string{"depth", "via", "kind", "id", "who", "summary", "url"},
+		Vals:  []string{itoa(n.Depth), string(n.Via), string(n.Kind), id, who, summary, url},
+		Value: n,
+	}
+}
+
 func videoRow(v youtube.Video) Row {
 	return Row{
 		Cols: []string{"id", "title", "channel", "duration", "views", "published", "url"},
diff --git a/docs/content/guides/graph-discovery.md b/docs/content/guides/graph-discovery.md
new file mode 100644
index 0000000..3311a9e
--- /dev/null
+++ b/docs/content/guides/graph-discovery.md
@@ -0,0 +1,125 @@
+---
+title: "Graph discovery"
+description: "Walk the graph linked from a video, channel, or playlist breadth-first with ytb discover: follow uploaders, related videos, uploads, playlists, items, owners, community posts, comments, and the channels behind them."
+weight: 55
+---
+
+Every other read answers one question about one object: a video's metadata, a
+channel's uploads, a playlist's items. `ytb discover` chains them. It starts at a
+video, channel, or playlist and follows that object's links outward, hop by hop,
+streaming every node it reaches. It is a breadth-first walk of the YouTube graph.
+
+```sh
+ytb discover dQw4w9WgXcQ      # what is this video linked to?
+ytb discover @MrBeast         # what is this channel linked to?
+```
+
+A *seed* is any reference ytb already resolves: a video id or watch URL, a
+channel id, `@handle`, or URL, or a playlist id or URL. Pass more than one to
+start the walk from several places at once.
+
+## What gets followed
+
+The default follows an object's **content**: a video yields its uploader and
+related videos, a channel yields its uploads, a playlist yields its items and
+owner. It spans every seed kind, so `ytb discover <anything>` does the obvious
+thing with no flags, and it stays on the open surface, so it needs nothing
+special.
+
+Choose what to follow with `--follow`. It takes a preset:
+
+```sh
+ytb discover <ref> --follow content    # uploader, related, uploads, items, owner (default)
+ytb discover <channel> --follow feed   # uploads, playlists, community posts
+ytb discover <video> --follow comments # comments and the channels that wrote them
+ytb discover <ref> --follow all        # every edge
+```
+
+or a comma-separated list of individual edges:
+
+```sh
+ytb discover <video> --follow channel,related
+ytb discover <channel> --follow uploads,playlists
+```
+
+The full edge vocabulary:
+
+| Edge | From → to | Gated | What it follows |
+|---|---|---|---|
+| `channel` | video → channel | no | the channel that uploaded the video |
+| `related` | video → video | no | a related / recommended video |
+| `comments` | video → comment | yes | a comment on the video |
+| `uploads` | channel → video | no | a video the channel uploaded |
+| `playlists` | channel → playlist | no | a playlist the channel owns |
+| `community` | channel → post | no | a community-tab post |
+| `items` | playlist → video | no | a video in the playlist |
+| `owner` | playlist → channel | no | the channel that owns the playlist |
+| `commenter` | comment → channel | yes | the channel that wrote the comment |
+
+Name an edge directly to chase just that link: `--follow related` walks the
+recommendation graph, `--follow uploads` sweeps a channel.
+
+YouTube serves the open surface to an anonymous request, so there are no tiers to
+add. The only gated edges are the two that touch comments: YouTube applies a
+per-IP Restricted Mode to some networks, and when it refuses the comments,
+`ytb discover` notes it on stderr and keeps going on the rest of the graph rather
+than failing the walk. So `--follow all` from a flagged network still returns the
+channel, related videos, playlists, and everything else, with one advisory about
+the comments it could not read.
+
+## How far and how wide
+
+```sh
+ytb discover <ref> --depth 2          # follow two hops from the seed (default 1)
+ytb discover <ref> --fanout 50        # up to 50 neighbors per edge (default 25)
+ytb discover <ref> --fanout 0         # no per-edge cap
+ytb discover <ref> -n 1000            # stop after 1000 nodes total (default 500)
+```
+
+`--depth` is how many hops to follow; `0` emits only the seeds. `--fanout` caps
+how many neighbors each edge contributes per node, so one hop never pages a whole
+upload history unless you raise it. `-n/--limit` is the total node budget, the
+hard stop on a deep or wide walk; even `--fanout 0` stays bounded by it, so a
+walk always terminates.
+
+## Reading the output
+
+`ytb discover` streams one row per node, tagged with how it was reached:
+
+```text
+depth  via      kind     id           who        summary                url
+0               video    dQw4w9WgXcQ  RickAstley  Never Gonna Give You U https://youtu.be/dQw4w9WgXcQ
+1      channel  channel  UCuAXFkgsw1L  RickAstley  Rick Astley           https://youtube.com/channel/UCuAXFkgsw1L
+1      related  video    J---aiyznGQ  ...         Keyboard Cat           https://youtu.be/J---aiyznGQ
+```
+
+Because it streams through the same formatter as every read, it shapes and pipes
+the same way. The JSON forms carry the full typed node, with the nested video,
+channel, playlist, comment, or post:
+
+```sh
+ytb discover <ref> -o json | jq -r '.via + " -> " + (.video.id // .channel.id)'
+ytb discover @MrBeast --follow feed -o jsonl | jq -r '.video.id' | sort -u
+ytb discover <ref> --fields depth,via,who,url -o table
+```
+
+## Persisting a walk
+
+Add `--store` to write every node and edge into the local store as the walk
+streams, so you keep the graph as well as see it:
+
+```sh
+ytb discover @MrBeast --follow all --depth 2 --store
+ytb db query "select kind, count(*) from edges group by kind order by 2 desc"
+```
+
+Each node lands in its own typed table (`videos`, `channels`, `playlists`,
+`comments`, `community_posts`) and each traversed link lands in an `edges` table
+(`src`, `dst`, `kind`), so the graph is queryable afterwards. Re-walking is
+idempotent.
+
+When you want a dataset built from an explicit worklist rather than a live walk,
+reach for [the crawl queue](/guides/the-store/), which drains a queue of URLs you
+load yourself; `discover` is the complement that finds the worklist by walking.
+See [the local store](/guides/the-store/) for inspecting and exporting what you
+collect.
diff --git a/docs/content/guides/the-store.md b/docs/content/guides/the-store.md
index 18fb169..e8dc1e0 100644
--- a/docs/content/guides/the-store.md
+++ b/docs/content/guides/the-store.md
@@ -19,6 +19,22 @@ The store is pure Go (modernc.org/sqlite), so nothing links libsqlite and the
 binary stays static. Without `--db`, no database is ever created: the flag is the
 only thing that turns persistence on.
 
+## Persist a walk
+
+`ytb discover --store` tees a breadth-first walk into the store as a side effect,
+so a live walk doubles as a crawl. Every node lands in its typed table and every
+traversed link lands in an `edges` table, so the graph is queryable afterwards:
+
+```sh
+ytb discover @MrBeast --follow all --depth 2 --store
+ytb db query "select kind, count(*) from edges group by kind order by 2 desc"
+```
+
+See [graph discovery](/guides/graph-discovery/) for the full edge and preset
+vocabulary. The difference from the crawl queue below is direction: `discover`
+finds the worklist by walking the graph, while `seed`/`crawl` drain a worklist
+you load yourself.
+
 ## Building a crawl queue
 
 For larger collection runs, `seed`, `crawl`, `queue`, and `jobs` turn the store
diff --git a/docs/content/reference/cli.md b/docs/content/reference/cli.md
index 3a75e09..a5700e4 100644
--- a/docs/content/reference/cli.md
+++ b/docs/content/reference/cli.md
@@ -51,6 +51,7 @@ Persistent flags accepted by every command.
 | `community` | Community / posts tab |
 | `hashtag` | A hashtag feed |
 | `related` | The related-videos graph |
+| `discover` | Breadth-first walk of the graph linked from a video, channel, or playlist |
 | `suggest` | Search autocomplete suggestions |
 | `transcript` | Captions as text |
 | `formats` | Streaming formats (metadata only) |
@@ -155,6 +156,19 @@ Persistent flags accepted by every command.
 
 `ytb related <video-id|url> [--flags]`. The related-videos graph. No notable flags beyond the globals.
 
+## discover
+
+`ytb discover <seed>... [--flags]` (aliases `walk`, `graph`). Walk the graph of linked objects breadth-first from one or more seeds (a video, channel, or playlist reference), streaming one row per node reached. See [graph discovery](/guides/graph-discovery/).
+
+| Flag | Meaning |
+| --- | --- |
+| `--follow` | Edges to follow: a preset (`content`, `feed`, `comments`, `all`) or a comma-separated edge list (`channel`, `related`, `comments`, `uploads`, `playlists`, `community`, `items`, `owner`, `commenter`). Default `content` |
+| `--depth` | Hops to follow from each seed (default `1`; `0` = seeds only) |
+| `--fanout` | Max neighbors to follow per edge (default `25`; `0` = unlimited) |
+| `--store` | Persist every node into its typed table and each traversed edge into the `edges` table |
+
+The comment edges (`comments`, `commenter`) are served only when YouTube is not applying its per-IP Restricted Mode to this network; when it is, they are noted on stderr and skipped and the rest of the walk continues. `-n/--limit` is the total node budget (default `500`).
+
 ## suggest
 
 `ytb suggest <query> [--flags]`. Search autocomplete suggestions. No notable flags beyond the globals.
diff --git a/docs/content/release-notes/v0-4-0.md b/docs/content/release-notes/v0-4-0.md
new file mode 100644
index 0000000..9521599
--- /dev/null
+++ b/docs/content/release-notes/v0-4-0.md
@@ -0,0 +1,66 @@
+---
+title: "v0.4.0"
+linkTitle: "v0.4.0"
+description: "A new discover command walks the YouTube graph from a video, channel, or playlist breadth first, following uploaders, related videos, uploads, playlists, items, owners, community posts, and comments."
+weight: 9
+---
+
+Every other read answers one question about one object: a video's metadata, a
+channel's uploads, a playlist's items. This release adds `discover`, which chains
+them into a single breadth-first walk.
+
+## discover: walk the graph
+
+```sh
+ytb discover dQw4w9WgXcQ      # what is this video linked to?
+ytb discover @MrBeast         # what is this channel linked to?
+```
+
+A seed is any reference `ytb` already resolves: a video id or watch URL, a
+channel id, `@handle`, or URL, or a playlist id or URL. Pass several to start the
+walk from more than one place. The first row is the seed at depth 0; each later
+row is tagged with the edge it arrived by.
+
+## What gets followed
+
+The default `content` preset spans every seed kind: a video yields its uploader
+and related videos, a channel yields its uploads, a playlist yields its items and
+owner. The full edge vocabulary is `channel`, `related`, `comments`, `uploads`,
+`playlists`, `community`, `items`, `owner`, and `commenter`.
+
+```sh
+ytb discover <channel> --follow feed       # uploads, playlists, community posts
+ytb discover <video> --follow comments     # comments and the channels behind them
+ytb discover <ref> --follow all
+ytb discover <channel> --follow uploads,playlists
+```
+
+The presets are `content`, `feed`, `comments`, and `all`. YouTube serves the open
+surface to an anonymous request, so most edges need nothing. The two that touch
+comments are gated by the per-IP Restricted Mode some networks see; when YouTube
+refuses them, `discover` notes it on stderr and keeps going on the rest of the
+graph rather than failing the walk.
+
+## Bounds, output, and the store
+
+`--depth` (default 1; 0 emits only the seeds), `--fanout` (default 25; 0 for
+unlimited), and `-n` (default 500) keep a walk finite. `discover` streams one row
+per node through the same formatter as every read, and `--store` writes every
+node and edge into the local store as the walk streams, so the graph is queryable
+afterwards. See the [Graph discovery](/guides/graph-discovery/) guide for the
+full tour.
+
+## Install
+
+```sh
+go install github.com/tamnd/ytb-cli/cmd/ytb@latest
+```
+
+Prebuilt archives for Linux, macOS, and Windows, plus Linux packages (deb, rpm,
+apk) and checksums, are on the
+[release page](https://github.com/tamnd/ytb-cli/releases/tag/v0.4.0). The
+container image is on GHCR:
+
+```sh
+docker run --rm ghcr.io/tamnd/ytb:0.4.0 discover dQw4w9WgXcQ -n 10
+```
diff --git a/youtube/discover.go b/youtube/discover.go
new file mode 100644
index 0000000..3c14273
--- /dev/null
+++ b/youtube/discover.go
@@ -0,0 +1,684 @@
+package youtube
+
+import (
+	"context"
+	"fmt"
+	"strings"
+)
+
+// discover.go is the breadth-first graph walker. Every read in this package
+// answers one question about one object (a video's metadata, a channel's
+// uploads, a playlist's items); the walker chains them. From a seed video,
+// channel, or playlist it follows the object's links (a video's uploader and
+// related videos, a channel's uploads, playlists, and community posts, a
+// playlist's items and owner, a comment's author) and from each neighbor it
+// follows theirs, hop by hop, until it runs out of depth or budget.
+//
+// It is engine-agnostic on purpose: Walk talks to the small grapher interface,
+// not to *Client directly, so the traversal is hermetically testable with a
+// fake graph and *Client is just the production grapher.
+
+// NodeKind is the type of a node the walk visits.
+type NodeKind string
+
+const (
+	KindVideo    NodeKind = "video"
+	KindChannel  NodeKind = "channel"
+	KindPlaylist NodeKind = "playlist"
+	KindComment  NodeKind = "comment"
+	KindPost     NodeKind = "post"
+)
+
+// Edge names a link the walk can follow. The string is the public vocabulary:
+// it is what the user types in --follow, what lands in the store's edges.kind
+// column, and what a discovered node reports as the edge it arrived by.
+type Edge string
+
+const (
+	EdgeChannel   Edge = "channel"   // video -> the channel that uploaded it
+	EdgeRelated   Edge = "related"   // video -> a related/recommended video
+	EdgeComments  Edge = "comments"  // video -> a comment on it
+	EdgeUploads   Edge = "uploads"   // channel -> a video it uploaded
+	EdgePlaylists Edge = "playlists" // channel -> a playlist it owns
+	EdgeCommunity Edge = "community" // channel -> a community post
+	EdgeItems     Edge = "items"     // playlist -> a video in it
+	EdgeOwner     Edge = "owner"     // playlist -> the channel that owns it
+	EdgeCommenter Edge = "commenter" // comment -> the channel that wrote it
+)
+
+// allEdges is the full vocabulary, in a stable display order.
+var allEdges = []Edge{
+	EdgeChannel, EdgeRelated, EdgeComments,
+	EdgeUploads, EdgePlaylists, EdgeCommunity,
+	EdgeItems, EdgeOwner, EdgeCommenter,
+}
+
+// knownEdges indexes allEdges for validation.
+var knownEdges = func() map[Edge]bool {
+	m := make(map[Edge]bool, len(allEdges))
+	for _, e := range allEdges {
+		m[e] = true
+	}
+	return m
+}()
+
+// Target reports the kind of node an edge leads to.
+func (e Edge) Target() NodeKind {
+	switch e {
+	case EdgeChannel, EdgeOwner, EdgeCommenter:
+		return KindChannel
+	case EdgePlaylists:
+		return KindPlaylist
+	case EdgeComments:
+		return KindComment
+	case EdgeCommunity:
+		return KindPost
+	default:
+		return KindVideo
+	}
+}
+
+// gated reports whether an edge is the one YouTube hides behind its per-IP
+// anti-bot wall. Comments (and a commenter reached through them) are served only
+// when the request is not in Restricted Mode; every other edge is on the open
+// surface. The walker does not pre-drop a gated edge: it tries it and turns a
+// refusal into a note, so a walk always produces what it can.
+func (e Edge) gated() bool {
+	switch e {
+	case EdgeComments, EdgeCommenter:
+		return true
+	default:
+		return false
+	}
+}
+
+// EdgeSet is a chosen set of edges to follow.
+type EdgeSet map[Edge]bool
+
+// Has reports whether the set contains e (a nil set contains nothing).
+func (s EdgeSet) Has(e Edge) bool { return s[e] }
+
+// List returns the set's edges in stable display order.
+func (s EdgeSet) List() []Edge {
+	var out []Edge
+	for _, e := range allEdges {
+		if s[e] {
+			out = append(out, e)
+		}
+	}
+	return out
+}
+
+// String renders the set as a comma-separated, ordered list.
+func (s EdgeSet) String() string { return joinEdges(s.List()) }
+
+// edgePresets are the named bundles --follow accepts in place of listing edges.
+// They are the everyday intents: read the obvious neighbors of whatever you gave
+// (content), map what a channel publishes (feed), study who engaged (comments),
+// or take it all. Preset names are kept disjoint from edge names so no token is
+// ambiguous: to chase recommendations alone, name the edge (`--follow related`).
+//
+// content is the default and deliberately spans every seed kind: a video yields
+// its uploader and related videos, a channel yields its uploads, a playlist
+// yields its items and owner, so `ytb discover <anything>` does the obvious
+// thing with no flags. It stays entirely on the open surface (no comments).
+var edgePresets = map[string]EdgeSet{
+	"content":  newEdgeSet(EdgeChannel, EdgeRelated, EdgeUploads, EdgeItems, EdgeOwner),
+	"feed":     newEdgeSet(EdgeUploads, EdgePlaylists, EdgeCommunity),
+	"comments": newEdgeSet(EdgeComments, EdgeCommenter),
+	"all":      newEdgeSet(allEdges...),
+}
+
+// presetNames lists the presets in a friendly order for help text.
+var presetNames = []string{"content", "feed", "comments", "all"}
+
+func newEdgeSet(edges ...Edge) EdgeSet {
+	s := make(EdgeSet, len(edges))
+	for _, e := range edges {
+		s[e] = true
+	}
+	return s
+}
+
+// DefaultEdges is what a walk follows when --follow is unset: the obvious
+// neighbors of the seed, all on the open surface, so `ytb discover <video>`
+// works with no tokens and no anti-bot exposure.
+func DefaultEdges() EdgeSet { return edgePresets["content"].clone() }
+
+func (s EdgeSet) clone() EdgeSet {
+	out := make(EdgeSet, len(s))
+	for e := range s {
+		out[e] = true
+	}
+	return out
+}
+
+// EdgeHelp is the one-line catalogue of presets and edges for flag help and
+// usage errors, so the names a user can type live in exactly one place.
+func EdgeHelp() string {
+	return "presets: " + strings.Join(presetNames, ",") + "; edges: " + joinEdges(allEdges)
+}
+
+// ParseEdges turns a --follow spec into an EdgeSet. The spec is a comma list of
+// preset names and/or edge names ("content", "channel,related", "uploads,items").
+// An empty spec yields DefaultEdges. An unknown token is a usage error naming the
+// catalogue, so a typo points the user at the real vocabulary.
+func ParseEdges(spec string) (EdgeSet, error) {
+	spec = strings.TrimSpace(spec)
+	if spec == "" {
+		return DefaultEdges(), nil
+	}
+	set := EdgeSet{}
+	for _, part := range strings.Split(spec, ",") {
+		p := strings.ToLower(strings.TrimSpace(part))
+		if p == "" {
+			continue
+		}
+		if preset, ok := edgePresets[p]; ok {
+			for e := range preset {
+				set[e] = true
+			}
+			continue
+		}
+		e := Edge(p)
+		if !knownEdges[e] {
+			return nil, fmt.Errorf("unknown edge or preset %q (%s)", p, EdgeHelp())
+		}
+		set[e] = true
+	}
+	if len(set) == 0 {
+		return nil, fmt.Errorf("no edges selected (%s)", EdgeHelp())
+	}
+	return set, nil
+}
+
+func joinEdges(edges []Edge) string {
+	ss := make([]string, len(edges))
+	for i, e := range edges {
+		ss[i] = string(e)
+	}
+	return strings.Join(ss, ",")
+}
+
+// Node is one object the walk reached, tagged with how it got there: the BFS
+// depth, the edge it arrived by, and the endpoint of the node it came from.
+// Exactly one of the entity pointers is set, matching Kind. Node is what Walk
+// hands to its callback and what the CLI renders.
+type Node struct {
+	Kind     NodeKind       `json:"kind"`
+	Depth    int            `json:"depth"`
+	Via      Edge           `json:"via,omitempty"`
+	Parent   string         `json:"parent,omitempty"`
+	Video    *Video         `json:"video,omitempty"`
+	Channel  *Channel       `json:"channel,omitempty"`
+	Playlist *Playlist      `json:"playlist,omitempty"`
+	Comment  *Comment       `json:"comment,omitempty"`
+	Post     *CommunityPost `json:"post,omitempty"`
+}
+
+// Endpoint is the node's stable identifier inside a walk: a video/channel/
+// playlist/comment/post id. It is what edges record as src/dst.
+func (n *Node) Endpoint() string {
+	switch n.Kind {
+	case KindVideo:
+		if n.Video != nil {
+			return n.Video.VideoID
+		}
+	case KindChannel:
+		if n.Channel != nil {
+			return n.Channel.ChannelID
+		}
+	case KindPlaylist:
+		if n.Playlist != nil {
+			return n.Playlist.PlaylistID
+		}
+	case KindComment:
+		if n.Comment != nil {
+			return n.Comment.ID
+		}
+	case KindPost:
+		if n.Post != nil {
+			return n.Post.PostID
+		}
+	}
+	return ""
+}
+
+// key is the dedup key for a hydrated node. Channels key on a lowercased id so
+// the same channel reached as an uploader and as a commenter collapses to one
+// node; the rest key on their id with a per-kind prefix so a video and a
+// playlist that happened to share an id never collide.
+func (n *Node) key() string {
+	switch n.Kind {
+	case KindVideo:
+		return "v:" + nodeID(n.Video != nil, func() string { return n.Video.VideoID })
+	case KindChannel:
+		return "c:" + strings.ToLower(nodeID(n.Channel != nil, func() string { return n.Channel.ChannelID }))
+	case KindPlaylist:
+		return "p:" + nodeID(n.Playlist != nil, func() string { return n.Playlist.PlaylistID })
+	case KindComment:
+		return "m:" + nodeID(n.Comment != nil, func() string { return n.Comment.ID })
+	case KindPost:
+		return "o:" + nodeID(n.Post != nil, func() string { return n.Post.PostID })
+	}
+	return ""
+}
+
+func nodeID(ok bool, get func() string) string {
+	if ok {
+		return get()
+	}
+	return ""
+}
+
+// Seed is a parsed starting point for a walk.
+type Seed struct {
+	Kind NodeKind
+	Ref  string // canonical id / @handle to fetch
+}
+
+// ParseSeed classifies a raw reference into a Seed, reusing the domain's own
+// Classify so a seed is read exactly like any other youtube reference: a watch
+// link or bare video id is a video, a playlist link or PL-style id is a
+// playlist, a @handle or UC id or channel URL is a channel.
+func ParseSeed(ref string) (Seed, error) {
+	t, id, err := Domain{}.Classify(ref)
+	if err != nil {
+		return Seed{}, err
+	}
+	switch t {
+	case "video":
+		return Seed{Kind: KindVideo, Ref: id}, nil
+	case "channel":
+		return Seed{Kind: KindChannel, Ref: id}, nil
+	case "playlist":
+		return Seed{Kind: KindPlaylist, Ref: id}, nil
+	default:
+		return Seed{}, fmt.Errorf("cannot start a walk from a %s reference: %q", t, ref)
+	}
+}
+
+// WalkOptions tunes a traversal.
+type WalkOptions struct {
+	Depth  int     // hops to follow from each seed (0 = seeds only)
+	Max    int     // stop after emitting this many nodes (0 = unlimited)
+	Fanout int     // per-edge neighbor cap (0 = unlimited)
+	Edges  EdgeSet // edges to follow (nil = DefaultEdges)
+
+	// OnEdge, if set, is called for every edge the walk traverses, before the
+	// neighbor is visited, with the two endpoints and the edge. The store sink
+	// uses it to record the graph; it fires even for an already-visited neighbor
+	// so the edge list stays complete.
+	OnEdge func(src, dst string, edge Edge)
+
+	// Note, if set, surfaces a one-line advisory (a comment edge refused by the
+	// anti-bot wall, a neighbor that could not be fetched). It never carries a
+	// fatal error.
+	Note func(string)
+}
+
+// grapher is the slice of the client the walker needs. *Client satisfies it; a
+// test supplies a fake. Every method matches *Client exactly.
+type grapher interface {
+	FetchVideo(ctx context.Context, ref string, opt VideoOptions) (*VideoResult, error)
+	FetchChannel(ctx context.Context, ref string) (*Channel, error)
+	FetchPlaylist(ctx context.Context, ref string) (*Playlist, error)
+	StreamChannelTab(ctx context.Context, ref, tab string, opt PageOptions, emit func(Video) error) error
+	StreamChannelPlaylists(ctx context.Context, ref string, opt PageOptions, emit func(Playlist) error) error
+	StreamPlaylistItems(ctx context.Context, ref string, opt PageOptions, emit func(PlaylistVideo, Video) error) error
+	StreamComments(ctx context.Context, ref string, opt CommentOptions, emit func(Comment) error) error
+	StreamCommunity(ctx context.Context, ref string, opt PageOptions, emit func(CommunityPost) error) error
+}
+
+var _ grapher = (*Client)(nil)
+
+// Walker performs the breadth-first traversal over a grapher.
+type Walker struct{ g grapher }
+
+// NewWalker builds a Walker over any grapher (the client in production, a fake
+// in tests).
+func NewWalker(g grapher) *Walker { return &Walker{g: g} }
+
+// Walk runs the client's traversal. It is the production entry point: it builds
+// a Walker over the client and walks the seeds. See Walker.Walk.
+func (c *Client) Walk(ctx context.Context, seeds []Seed, opts WalkOptions, emit func(*Node) error) error {
+	return NewWalker(c).Walk(ctx, seeds, opts, emit)
+}
+
+// frontier is a queued, possibly-not-yet-hydrated node. Stream reads (a
+// channel's uploads, a playlist's items) hand back fully built entities, so
+// those rides carry the entity already and skip the per-pop fetch; a reference
+// reached as an id (an uploader channel, a related video) carries only the id
+// and is fetched when it is popped.
+type frontier struct {
+	kind     NodeKind
+	ref      string
+	depth    int
+	via      Edge
+	parent   string
+	video    *Video
+	channel  *Channel
+	playlist *Playlist
+	comment  *Comment
+	post     *CommunityPost
+}
+
+func (f frontier) key() string {
+	switch f.kind {
+	case KindVideo:
+		id := f.ref
+		if f.video != nil {
+			id = f.video.VideoID
+		}
+		return "v:" + id
+	case KindChannel:
+		id := f.ref
+		if f.channel != nil {
+			id = f.channel.ChannelID
+		}
+		return "c:" + strings.ToLower(id)
+	case KindPlaylist:
+		id := f.ref
+		if f.playlist != nil {
+			id = f.playlist.PlaylistID
+		}
+		return "p:" + id
+	case KindComment:
+		if f.comment != nil {
+			return "m:" + f.comment.ID
+		}
+		return "m:" + f.ref
+	case KindPost:
+		if f.post != nil {
+			return "o:" + f.post.PostID
+		}
+		return "o:" + f.ref
+	}
+	return ""
+}
+
+// Walk visits the seeds and their links in breadth-first order, calling emit for
+// each node as it is reached. It returns when the queue drains, the node budget
+// (opts.Max) is hit, emit returns an error, or a seed cannot be fetched. A
+// gated edge (comments, and the channel reached through one) is attempted, not
+// pre-dropped: when YouTube refuses it the failure becomes a Note and the walk
+// keeps going on the rest of the graph.
+func (w *Walker) Walk(ctx context.Context, seeds []Seed, opts WalkOptions, emit func(*Node) error) error {
+	edges := opts.Edges
+	if edges == nil {
+		edges = DefaultEdges()
+	}
+
+	visited := map[string]bool{}
+	queue := make([]frontier, 0, len(seeds))
+	for _, s := range seeds {
+		queue = append(queue, frontier{kind: s.Kind, ref: s.Ref})
+	}
+
+	emitted := 0
+	for len(queue) > 0 {
+		if err := ctx.Err(); err != nil {
+			return err
+		}
+		f := queue[0]
+		queue = queue[1:]
+		if visited[f.key()] {
+			continue
+		}
+		visited[f.key()] = true
+
+		node, vres, err := w.hydrate(ctx, f, f.depth < opts.Depth, edges)
+		if err != nil {
+			if f.depth == 0 {
+				return err // a seed we cannot fetch is fatal, like a single read
+			}
+			if opts.Note != nil {
+				opts.Note(fmt.Sprintf("skip %s %s: %v", f.kind, f.ref, err))
+			}
+			continue
+		}
+		if node == nil {
+			continue
+		}
+		visited[node.key()] = true // collapse handle/id aliases of the same node
+
+		if err := emit(node); err != nil {
+			return err
+		}
+		emitted++
+		if opts.Max > 0 && emitted >= opts.Max {
+			return nil
+		}
+		if f.depth >= opts.Depth {
+			continue
+		}
+		for _, nb := range w.neighbors(ctx, node, vres, edges, opts) {
+			if !visited[nb.key()] {
+				queue = append(queue, nb)
+			}
+		}
+	}
+	return nil
+}
+
+// hydrate turns a frontier item into a Node, fetching the object when the item
+// did not already carry it. It returns the VideoResult alongside a video node so
+// neighbors can read the related list and comment availability without a second
+// fetch. expand says whether this node will be expanded (its depth is below the
+// limit); a video carried in from a stream is only refetched (for its related
+// list) when it will actually be expanded along the related edge.
+func (w *Walker) hydrate(ctx context.Context, f frontier, expand bool, edges EdgeSet) (*Node, *VideoResult, error) {
+	n := &Node{Kind: f.kind, Depth: f.depth, Via: f.via, Parent: f.parent}
+	switch f.kind {
+	case KindVideo:
+		wantRelated := expand && edges.Has(EdgeRelated)
+		if f.video == nil {
+			res, err := w.g.FetchVideo(ctx, f.ref, VideoOptions{Next: wantRelated})
+			if err != nil {
+				return nil, nil, err
+			}
+			if res == nil {
+				return nil, nil, fmt.Errorf("video not found: %s", f.ref)
+			}
+			n.Video = &res.Video
+			return n, res, nil
+		}
+		n.Video = f.video
+		if wantRelated {
+			// The streamed video has no related list; fetch it once so the related
+			// edge has something to expand. A failure here is not fatal: keep the
+			// node we already have and expand whatever else applies.
+			if res, err := w.g.FetchVideo(ctx, f.video.VideoID, VideoOptions{Next: true}); err == nil && res != nil {
+				n.Video = &res.Video
+				return n, res, nil
+			}
+		}
+		return n, nil, nil
+	case KindChannel:
+		if f.channel == nil {
+			ch, err := w.g.FetchChannel(ctx, f.ref)
+			if err != nil {
+				return nil, nil, err
+			}
+			if ch == nil {
+				return nil, nil, fmt.Errorf("channel not found: %s", f.ref)
+			}
+			n.Channel = ch
+		} else {
+			n.Channel = f.channel
+		}
+		return n, nil, nil
+	case KindPlaylist:
+		if f.playlist == nil {
+			pl, err := w.g.FetchPlaylist(ctx, f.ref)
+			if err != nil {
+				return nil, nil, err
+			}
+			if pl == nil {
+				return nil, nil, fmt.Errorf("playlist not found: %s", f.ref)
+			}
+			n.Playlist = pl
+		} else {
+			n.Playlist = f.playlist
+		}
+		return n, nil, nil
+	case KindComment:
+		n.Comment = f.comment
+		return n, nil, nil
+	case KindPost:
+		n.Post = f.post
+		return n, nil, nil
+	}
+	return nil, nil, nil
+}
+
+// neighbors expands a node into its outbound frontier under the chosen edges,
+// recording each edge via opts.OnEdge. The per-edge fanout caps every stream
+// read and the inline related loop, so one hop can never page a channel's whole
+// upload history unless the caller asked for it (Fanout 0).
+func (w *Walker) neighbors(ctx context.Context, n *Node, vres *VideoResult, edges EdgeSet, opts WalkOptions) []frontier {
+	var out []frontier
+	src := n.Endpoint()
+	streamMax := opts.Fanout
+	if streamMax <= 0 {
+		streamMax = opts.Max // bound an uncapped stream by the node budget
+	}
+
+	addEdge := func(dst string, via Edge) {
+		if opts.OnEdge != nil {
+			opts.OnEdge(src, dst, via)
+		}
+	}
+
+	switch n.Kind {
+	case KindVideo:
+		v := n.Video
+		if edges.Has(EdgeChannel) && v.ChannelID != "" {
+			addEdge(v.ChannelID, EdgeChannel)
+			out = append(out, frontier{kind: KindChannel, ref: v.ChannelID, depth: n.Depth + 1, via: EdgeChannel, parent: src})
+		}
+		if edges.Has(EdgeRelated) && vres != nil {
+			i := 0
+			for _, r := range vres.Related {
+				if r.RelatedVideoID == "" {
+					continue
+				}
+				if streamMax > 0 && i >= streamMax {
+					break
+				}
+				addEdge(r.RelatedVideoID, EdgeRelated)
+				out = append(out, frontier{kind: KindVideo, ref: r.RelatedVideoID, depth: n.Depth + 1, via: EdgeRelated, parent: src})
+				i++
+			}
+		}
+		if edges.Has(EdgeComments) {
+			i := 0
+			err := w.g.StreamComments(ctx, v.VideoID, CommentOptions{Max: streamMax}, func(cm Comment) error {
+				c := cm
+				addEdge(c.ID, EdgeComments)
+				out = append(out, frontier{kind: KindComment, depth: n.Depth + 1, via: EdgeComments, parent: src, comment: &c})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+	case KindChannel:
+		ch := n.Channel
+		ref := ch.ChannelID
+		if edges.Has(EdgeUploads) {
+			i := 0
+			err := w.g.StreamChannelTab(ctx, ref, "videos", PageOptions{Max: streamMax}, func(v Video) error {
+				vv := v
+				addEdge(vv.VideoID, EdgeUploads)
+				out = append(out, frontier{kind: KindVideo, depth: n.Depth + 1, via: EdgeUploads, parent: src, video: &vv})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+		if edges.Has(EdgePlaylists) {
+			i := 0
+			err := w.g.StreamChannelPlaylists(ctx, ref, PageOptions{Max: streamMax}, func(p Playlist) error {
+				pp := p
+				addEdge(pp.PlaylistID, EdgePlaylists)
+				out = append(out, frontier{kind: KindPlaylist, depth: n.Depth + 1, via: EdgePlaylists, parent: src, playlist: &pp})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+		if edges.Has(EdgeCommunity) {
+			i := 0
+			err := w.g.StreamCommunity(ctx, ref, PageOptions{Max: streamMax}, func(p CommunityPost) error {
+				pp := p
+				addEdge(pp.PostID, EdgeCommunity)
+				out = append(out, frontier{kind: KindPost, depth: n.Depth + 1, via: EdgeCommunity, parent: src, post: &pp})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+	case KindPlaylist:
+		pl := n.Playlist
+		ref := pl.PlaylistID
+		if edges.Has(EdgeItems) {
+			i := 0
+			err := w.g.StreamPlaylistItems(ctx, ref, PageOptions{Max: streamMax}, func(pv PlaylistVideo, v Video) error {
+				vv := v
+				if vv.VideoID == "" {
+					vv.VideoID = pv.VideoID
+				}
+				if vv.URL == "" {
+					vv.URL = "https://www.youtube.com/watch?v=" + vv.VideoID
+				}
+				addEdge(vv.VideoID, EdgeItems)
+				out = append(out, frontier{kind: KindVideo, depth: n.Depth + 1, via: EdgeItems, parent: src, video: &vv})
+				i++
+				if streamMax > 0 && i >= streamMax {
+					return ErrStop
+				}
+				return nil
+			})
+			w.note(opts, err)
+		}
+		if edges.Has(EdgeOwner) && pl.ChannelID != "" {
+			addEdge(pl.ChannelID, EdgeOwner)
+			out = append(out, frontier{kind: KindChannel, ref: pl.ChannelID, depth: n.Depth + 1, via: EdgeOwner, parent: src})
+		}
+	case KindComment:
+		cm := n.Comment
+		if edges.Has(EdgeCommenter) && cm.AuthorChannelID != "" {
+			addEdge(cm.AuthorChannelID, EdgeCommenter)
+			out = append(out, frontier{kind: KindChannel, ref: cm.AuthorChannelID, depth: n.Depth + 1, via: EdgeCommenter, parent: src})
+		}
+	case KindPost:
+		// A community post is a leaf in this graph: its author is the channel we
+		// arrived from, so it has no outbound edge of its own.
+	}
+	return out
+}
+
+// note surfaces a non-fatal stream failure (a comment edge refused by the
+// anti-bot wall, a channel with no community tab, a transient rate limit) as an
+// advisory and keeps the walk going on the rest of the graph. The clean stop
+// sentinel ErrStop is not a failure.
+func (w *Walker) note(opts WalkOptions, err error) {
+	if err == nil || err == ErrStop {
+		return
+	}
+	if opts.Note != nil {
+		opts.Note(err.Error())
+	}
+}
diff --git a/youtube/discover_test.go b/youtube/discover_test.go
new file mode 100644
index 0000000..fd84505
--- /dev/null
+++ b/youtube/discover_test.go
@@ -0,0 +1,407 @@
+package youtube
+
+import (
+	"context"
+	"errors"
+	"sort"
+	"testing"
+)
+
+// fakeGraph is an in-memory grapher: a tiny YouTube whose edges are fixed maps,
+// so the walker is tested without a network. It implements every grapher method
+// the walker calls.
+type fakeGraph struct {
+	videos    map[string]*VideoResult    // video id -> result (with Related)
+	channels  map[string]*Channel        // ref (id/handle) -> channel
+	playlists map[string]*Playlist       // playlist id -> playlist
+	uploads   map[string][]Video         // channel id -> uploaded videos
+	plLists   map[string][]Playlist      // channel id -> playlists
+	community map[string][]CommunityPost // channel id -> posts
+	items     map[string][]Video         // playlist id -> videos
+	comments  map[string][]Comment       // video id -> comments
+	commErr   map[string]error           // video id -> error from StreamComments
+}
+
+func (g *fakeGraph) FetchVideo(_ context.Context, ref string, _ VideoOptions) (*VideoResult, error) {
+	if v, ok := g.videos[ref]; ok {
+		return v, nil
+	}
+	return nil, errors.New("video not found: " + ref)
+}
+
+func (g *fakeGraph) FetchChannel(_ context.Context, ref string) (*Channel, error) {
+	if c, ok := g.channels[ref]; ok {
+		return c, nil
+	}
+	return nil, errors.New("channel not found: " + ref)
+}
+
+func (g *fakeGraph) FetchPlaylist(_ context.Context, ref string) (*Playlist, error) {
+	if p, ok := g.playlists[ref]; ok {
+		return p, nil
+	}
+	return nil, errors.New("playlist not found: " + ref)
+}
+
+func streamSlice[T any](items []T, max int, emit func(T) error) error {
+	n := 0
+	for _, it := range items {
+		if max > 0 && n >= max {
+			return nil
+		}
+		if err := emit(it); err != nil {
+			if err == ErrStop {
+				return nil
+			}
+			return err
+		}
+		n++
+	}
+	return nil
+}
+
+func (g *fakeGraph) StreamChannelTab(_ context.Context, ref, _ string, opt PageOptions, emit func(Video) error) error {
+	return streamSlice(g.uploads[ref], opt.Max, emit)
+}
+
+func (g *fakeGraph) StreamChannelPlaylists(_ context.Context, ref string, opt PageOptions, emit func(Playlist) error) error {
+	return streamSlice(g.plLists[ref], opt.Max, emit)
+}
+
+func (g *fakeGraph) StreamPlaylistItems(_ context.Context, ref string, opt PageOptions, emit func(PlaylistVideo, Video) error) error {
+	vids := g.items[ref]
+	n := 0
+	for i, v := range vids {
+		if opt.Max > 0 && n >= opt.Max {
+			return nil
+		}
+		if err := emit(PlaylistVideo{PlaylistID: ref, VideoID: v.VideoID, Position: i}, v); err != nil {
+			if err == ErrStop {
+				return nil
+			}
+			return err
+		}
+		n++
+	}
+	return nil
+}
+
+func (g *fakeGraph) StreamComments(_ context.Context, ref string, opt CommentOptions, emit func(Comment) error) error {
+	if err, ok := g.commErr[ref]; ok {
+		return err
+	}
+	return streamSlice(g.comments[ref], opt.Max, emit)
+}
+
+func (g *fakeGraph) StreamCommunity(_ context.Context, ref string, opt PageOptions, emit func(CommunityPost) error) error {
+	return streamSlice(g.community[ref], opt.Max, emit)
+}
+
+// newFakeGraph builds a small connected corpus:
+//
+//	video vid1 (channel UCa, related [vid2])
+//	channel UCa (uploads [vid1, vid3], playlists [PLaaaaaaaaaa], community [post1])
+//	playlist PLaaaaaaaaaa (items [vid1, vid2], owner UCa)
+//	comments on vid1: cmt1 by UCb
+//	channel UCb (the commenter)
+func newFakeGraph() *fakeGraph {
+	vid1 := &VideoResult{
+		Video:   Video{VideoID: "vid1", Title: "First", ChannelID: "UCa", ChannelName: "Alpha"},
+		Related: []RelatedVideo{{VideoID: "vid1", RelatedVideoID: "vid2", Position: 0}},
+	}
+	vid2 := &VideoResult{Video: Video{VideoID: "vid2", Title: "Second", ChannelID: "UCa", ChannelName: "Alpha"}}
+	vid3 := &VideoResult{Video: Video{VideoID: "vid3", Title: "Third", ChannelID: "UCa", ChannelName: "Alpha"}}
+	return &fakeGraph{
+		videos: map[string]*VideoResult{"vid1": vid1, "vid2": vid2, "vid3": vid3},
+		channels: map[string]*Channel{
+			"UCa": {ChannelID: "UCa", Handle: "@alpha", Title: "Alpha"},
+			"UCb": {ChannelID: "UCb", Handle: "@beta", Title: "Beta"},
+		},
+		playlists: map[string]*Playlist{
+			"PLaaaaaaaaaa": {PlaylistID: "PLaaaaaaaaaa", Title: "Mix", ChannelID: "UCa", ChannelName: "Alpha"},
+		},
+		uploads: map[string][]Video{
+			"UCa": {{VideoID: "vid1", Title: "First", ChannelID: "UCa"}, {VideoID: "vid3", Title: "Third", ChannelID: "UCa"}},
+		},
+		plLists: map[string][]Playlist{
+			"UCa": {{PlaylistID: "PLaaaaaaaaaa", Title: "Mix", ChannelID: "UCa"}},
+		},
+		community: map[string][]CommunityPost{
+			"UCa": {{PostID: "post1", ChannelID: "UCa", AuthorName: "Alpha", ContentText: "hello"}},
+		},
+		items: map[string][]Video{
+			"PLaaaaaaaaaa": {{VideoID: "vid1"}, {VideoID: "vid2"}},
+		},
+		comments: map[string][]Comment{
+			"vid1": {{ID: "cmt1", VideoID: "vid1", AuthorChannelID: "UCb", AuthorDisplayName: "Beta", TextDisplay: "nice"}},
+		},
+	}
+}
+
+// collect walks and returns the emitted nodes plus the recorded edges.
+func collect(t *testing.T, g grapher, seeds []Seed, opts WalkOptions) ([]*Node, []string) {
+	t.Helper()
+	var edgesLog []string
+	opts.OnEdge = func(src, dst string, e Edge) {
+		edgesLog = append(edgesLog, string(e)+":"+src+"->"+dst)
+	}
+	var nodes []*Node
+	err := NewWalker(g).Walk(context.Background(), seeds, opts, func(n *Node) error {
+		nodes = append(nodes, n)
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("Walk: %v", err)
+	}
+	return nodes, edgesLog
+}
+
+func TestParseEdges(t *testing.T) {
+	t.Run("empty is the content default", func(t *testing.T) {
+		set, err := ParseEdges("")
+		if err != nil {
+			t.Fatal(err)
+		}
+		want := edgePresets["content"]
+		if set.String() != want.String() {
+			t.Fatalf("got %q, want %q", set, want)
+		}
+	})
+	t.Run("preset expands", func(t *testing.T) {
+		set, err := ParseEdges("comments")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !set.Has(EdgeComments) || !set.Has(EdgeCommenter) {
+			t.Fatalf("comments preset missing edges: %q", set)
+		}
+	})
+	t.Run("list of edges and presets", func(t *testing.T) {
+		set, err := ParseEdges("related,channel,uploads")
+		if err != nil {
+			t.Fatal(err)
+		}
+		for _, e := range []Edge{EdgeRelated, EdgeChannel, EdgeUploads} {
+			if !set.Has(e) {
+				t.Fatalf("missing %s in %q", e, set)
+			}
+		}
+	})
+	t.Run("unknown token errors", func(t *testing.T) {
+		if _, err := ParseEdges("nope"); err == nil {
+			t.Fatal("expected error for unknown token")
+		}
+	})
+}
+
+func TestEdgeTargetAndGated(t *testing.T) {
+	cases := map[Edge]NodeKind{
+		EdgeChannel: KindChannel, EdgeOwner: KindChannel, EdgeCommenter: KindChannel,
+		EdgePlaylists: KindPlaylist, EdgeComments: KindComment, EdgeCommunity: KindPost,
+		EdgeRelated: KindVideo, EdgeUploads: KindVideo, EdgeItems: KindVideo,
+	}
+	for e, want := range cases {
+		if got := e.Target(); got != want {
+			t.Errorf("%s.Target() = %s, want %s", e, got, want)
+		}
+	}
+	for _, e := range []Edge{EdgeComments, EdgeCommenter} {
+		if !e.gated() {
+			t.Errorf("%s should be gated", e)
+		}
+	}
+	for _, e := range []Edge{EdgeChannel, EdgeRelated, EdgeUploads, EdgeItems, EdgeOwner, EdgePlaylists, EdgeCommunity} {
+		if e.gated() {
+			t.Errorf("%s should not be gated", e)
+		}
+	}
+}
+
+func TestParseSeed(t *testing.T) {
+	cases := []struct {
+		in   string
+		kind NodeKind
+		ref  string
+	}{
+		{"https://www.youtube.com/watch?v=dQw4w9WgXcQ", KindVideo, "dQw4w9WgXcQ"},
+		{"dQw4w9WgXcQ", KindVideo, "dQw4w9WgXcQ"},
+		{"UCabcdefghijklmnopqrst12", KindChannel, "UCabcdefghijklmnopqrst12"},
+		{"@mkbhd", KindChannel, "@mkbhd"},
+		{"PLaaaaaaaaaaaa", KindPlaylist, "PLaaaaaaaaaaaa"},
+		{"https://www.youtube.com/playlist?list=PLxyz", KindPlaylist, "PLxyz"},
+	}
+	for _, c := range cases {
+		s, err := ParseSeed(c.in)
+		if err != nil {
+			t.Errorf("ParseSeed(%q): %v", c.in, err)
+			continue
+		}
+		if s.Kind != c.kind || s.Ref != c.ref {
+			t.Errorf("ParseSeed(%q) = %s/%s, want %s/%s", c.in, s.Kind, s.Ref, c.kind, c.ref)
+		}
+	}
+	if _, err := ParseSeed(""); err == nil {
+		t.Error("expected error for empty seed")
+	}
+}
+
+func TestWalkContentFromVideo(t *testing.T) {
+	g := newFakeGraph()
+	nodes, edges := collect(t, g, []Seed{{Kind: KindVideo, Ref: "vid1"}}, WalkOptions{Depth: 1, Edges: DefaultEdges()})
+
+	// Breadth-first: the seed first, then its channel and related video.
+	if len(nodes) != 3 {
+		t.Fatalf("got %d nodes, want 3: %+v", len(nodes), endpoints(nodes))
+	}
+	if nodes[0].Kind != KindVideo || nodes[0].Depth != 0 {
+		t.Fatalf("seed node = %s depth %d", nodes[0].Kind, nodes[0].Depth)
+	}
+	got := endpoints(nodes[1:])
+	sort.Strings(got)
+	want := []string{"UCa", "vid2"}
+	if !equal(got, want) {
+		t.Fatalf("neighbors = %v, want %v", got, want)
+	}
+	if !contains(edges, "channel:vid1->UCa") || !contains(edges, "related:vid1->vid2") {
+		t.Fatalf("edges = %v", edges)
+	}
+}
+
+func TestWalkChannelPreset(t *testing.T) {
+	g := newFakeGraph()
+	nodes, _ := collect(t, g, []Seed{{Kind: KindChannel, Ref: "UCa"}}, WalkOptions{Depth: 1, Edges: edgePresets["feed"]})
+	got := endpoints(nodes)
+	sort.Strings(got)
+	// seed UCa, uploads vid1+vid3, playlist PLaaaaaaaaaa, post1.
+	want := []string{"PLaaaaaaaaaa", "UCa", "post1", "vid1", "vid3"}
+	if !equal(got, want) {
+		t.Fatalf("channel walk = %v, want %v", got, want)
+	}
+}
+
+func TestWalkCommentsThenCommenter(t *testing.T) {
+	g := newFakeGraph()
+	// Depth 2 so the comment (depth 1) expands to its author channel (depth 2).
+	nodes, edges := collect(t, g, []Seed{{Kind: KindVideo, Ref: "vid1"}},
+		WalkOptions{Depth: 2, Edges: edgePresets["comments"]})
+	kinds := map[NodeKind]int{}
+	for _, n := range nodes {
+		kinds[n.Kind]++
+	}
+	if kinds[KindComment] != 1 {
+		t.Fatalf("want 1 comment node, got %d (%v)", kinds[KindComment], endpoints(nodes))
+	}
+	if kinds[KindChannel] != 1 {
+		t.Fatalf("want 1 commenter channel, got %d (%v)", kinds[KindChannel], endpoints(nodes))
+	}
+	if !contains(edges, "comments:vid1->cmt1") || !contains(edges, "commenter:cmt1->UCb") {
+		t.Fatalf("edges = %v", edges)
+	}
+}
+
+func TestWalkCommentsRestrictedDegrades(t *testing.T) {
+	g := newFakeGraph()
+	g.commErr = map[string]error{"vid1": ErrCommentsRestricted}
+	var notes []string
+	opts := WalkOptions{Depth: 1, Edges: edgePresets["comments"], Note: func(s string) { notes = append(notes, s) }}
+	var nodes []*Node
+	err := NewWalker(g).Walk(context.Background(), []Seed{{Kind: KindVideo, Ref: "vid1"}}, opts, func(n *Node) error {
+		nodes = append(nodes, n)
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("a gated comment edge must not be fatal: %v", err)
+	}
+	if len(nodes) != 1 || nodes[0].Kind != KindVideo {
+		t.Fatalf("want just the seed, got %v", endpoints(nodes))
+	}
+	if len(notes) == 0 {
+		t.Fatal("expected a note about the refused comment edge")
+	}
+}
+
+func TestWalkBudgetStops(t *testing.T) {
+	g := newFakeGraph()
+	nodes, _ := collect(t, g, []Seed{{Kind: KindVideo, Ref: "vid1"}}, WalkOptions{Depth: 1, Max: 2, Edges: DefaultEdges()})
+	if len(nodes) != 2 {
+		t.Fatalf("budget Max=2 should stop at 2, got %d", len(nodes))
+	}
+}
+
+func TestWalkFanoutCaps(t *testing.T) {
+	g := newFakeGraph()
+	// Channel uploads has 2 videos; fanout 1 keeps only one of them.
+	nodes, _ := collect(t, g, []Seed{{Kind: KindChannel, Ref: "UCa"}},
+		WalkOptions{Depth: 1, Fanout: 1, Edges: newEdgeSet(EdgeUploads)})
+	if len(nodes) != 2 { // seed + 1 upload
+		t.Fatalf("fanout 1 should yield seed + 1 upload, got %d (%v)", len(nodes), endpoints(nodes))
+	}
+}
+
+func TestWalkDedup(t *testing.T) {
+	g := newFakeGraph()
+	// From the playlist: items vid1, vid2 and owner UCa. UCa's uploads bring vid1
+	// and vid3 back; vid1 must not be emitted twice.
+	nodes, _ := collect(t, g, []Seed{{Kind: KindPlaylist, Ref: "PLaaaaaaaaaa"}},
+		WalkOptions{Depth: 2, Edges: newEdgeSet(EdgeItems, EdgeOwner, EdgeUploads)})
+	seen := map[string]int{}
+	for _, n := range nodes {
+		seen[n.Endpoint()]++
+	}
+	for id, c := range seen {
+		if c != 1 {
+			t.Fatalf("node %s emitted %d times, want 1 (%v)", id, c, endpoints(nodes))
+		}
+	}
+}
+
+func TestWalkSeedNotFoundFatal(t *testing.T) {
+	g := newFakeGraph()
+	err := NewWalker(g).Walk(context.Background(), []Seed{{Kind: KindVideo, Ref: "missing"}},
+		WalkOptions{Depth: 1, Edges: DefaultEdges()}, func(*Node) error { return nil })
+	if err == nil {
+		t.Fatal("an unfetchable seed must be fatal")
+	}
+}
+
+func TestWalkDepthZeroSeedsOnly(t *testing.T) {
+	g := newFakeGraph()
+	nodes, edges := collect(t, g, []Seed{{Kind: KindVideo, Ref: "vid1"}}, WalkOptions{Depth: 0, Edges: DefaultEdges()})
+	if len(nodes) != 1 {
+		t.Fatalf("depth 0 should emit only the seed, got %d", len(nodes))
+	}
+	if len(edges) != 0 {
+		t.Fatalf("depth 0 should record no edges, got %v", edges)
+	}
+}
+
+// --- small test helpers ---
+
+func endpoints(nodes []*Node) []string {
+	out := make([]string, len(nodes))
+	for i, n := range nodes {
+		out[i] = n.Endpoint()
+	}
+	return out
+}
+
+func contains(ss []string, want string) bool {
+	for _, s := range ss {
+		if s == want {
+			return true
+		}
+	}
+	return false
+}
+
+func equal(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if a[i] != b[i] {
+			return false
+		}
+	}
+	return true
+}
diff --git a/youtube/store.go b/youtube/store.go
index cc7c38e..c2bc753 100644
--- a/youtube/store.go
+++ b/youtube/store.go
@@ -201,6 +201,14 @@ func (s *Store) initSchema() error {
 			started_at   TEXT,
 			completed_at TEXT
 		)`,
+		`CREATE TABLE IF NOT EXISTS edges (
+			src        TEXT NOT NULL,
+			dst        TEXT NOT NULL,
+			kind       TEXT NOT NULL,
+			created_at TEXT,
+			PRIMARY KEY (src, dst, kind)
+		)`,
+		`CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind)`,
 	}
 	for _, stmt := range stmts {
 		if _, err := s.db.Exec(stmt); err != nil {
@@ -352,6 +360,50 @@ func (s *Store) UpsertCommunityPost(p CommunityPost) error {
 	return err
 }
 
+// --- Graph edges and nodes ---
+
+// UpsertEdge records one traversed link of the discovery graph, keyed by the
+// (src, dst, kind) triple so re-walking is idempotent. src and dst are entity
+// ids; kind is the Edge string ("channel", "uploads", ...). It is the sink
+// `ytb discover --store` feeds from WalkOptions.OnEdge.
+func (s *Store) UpsertEdge(src, dst, kind string) error {
+	_, err := s.db.Exec(
+		`INSERT OR IGNORE INTO edges (src, dst, kind, created_at) VALUES (?,?,?,?)`,
+		src, dst, kind, storeTime(time.Now()),
+	)
+	return err
+}
+
+// UpsertNode persists a discovered node into its typed table, dispatching on the
+// node kind. It is what `ytb discover --store` calls for every emitted node, so
+// a walk fills videos/channels/playlists/comments/community_posts exactly as the
+// per-object reads do, with the edges table joining them.
+func (s *Store) UpsertNode(n *Node) error {
+	switch n.Kind {
+	case KindVideo:
+		if n.Video != nil {
+			return s.UpsertVideo(*n.Video)
+		}
+	case KindChannel:
+		if n.Channel != nil {
+			return s.UpsertChannel(*n.Channel)
+		}
+	case KindPlaylist:
+		if n.Playlist != nil {
+			return s.UpsertPlaylist(*n.Playlist)
+		}
+	case KindComment:
+		if n.Comment != nil {
+			return s.UpsertComment(*n.Comment)
+		}
+	case KindPost:
+		if n.Post != nil {
+			return s.UpsertCommunityPost(*n.Post)
+		}
+	}
+	return nil
+}
+
 // --- VideoFormat ---
 
 func (s *Store) UpsertVideoFormat(f VideoFormat) error {
@@ -498,7 +550,7 @@ func (s *Store) Stats() (map[string]int64, error) {
 	tables := []string{
 		"videos", "channels", "playlists", "playlist_videos", "related_videos",
 		"caption_tracks", "comments", "chapters", "community_posts", "video_formats",
-		"queue", "jobs",
+		"queue", "jobs", "edges",
 	}
 	out := make(map[string]int64, len(tables))
 	for _, t := range tables {
@@ -618,7 +670,7 @@ func (s *Store) Reset() error {
 	tables := []string{
 		"videos", "channels", "playlists", "playlist_videos", "related_videos",
 		"caption_tracks", "comments", "chapters", "community_posts", "video_formats",
-		"queue", "jobs",
+		"queue", "jobs", "edges",
 	}
 	for _, t := range tables {
 		if _, err := s.db.Exec(`DROP TABLE IF EXISTS ` + t); err != nil {
