diff --git a/chat/app.py b/chat/app.py
index 0ae516c..80b0553 100644
--- a/chat/app.py
+++ b/chat/app.py
@@ -32,6 +32,7 @@ from chat.web.drawer import router as drawer_router
from chat.web.kickoff import router as kickoff_router
from chat.web.middleware import FirstRunRedirectMiddleware
from chat.web.nav import router as nav_router
+from chat.web.search import router as search_router
from chat.web.settings import router as settings_router
from chat.web.snapshots import router as snapshots_router
from chat.web.sse import router as sse_router
@@ -142,6 +143,7 @@ app.include_router(snapshots_router)
app.include_router(nav_router)
app.include_router(chat_router)
app.include_router(drawer_router)
+app.include_router(search_router)
app.include_router(sse_router)
app.include_router(turns_router)
diff --git a/chat/templates/layout.html b/chat/templates/layout.html
index 197a39b..1dd9dcc 100644
--- a/chat/templates/layout.html
+++ b/chat/templates/layout.html
@@ -8,6 +8,13 @@
{% block content %}{% endblock %}
diff --git a/chat/templates/search.html b/chat/templates/search.html
new file mode 100644
index 0000000..ee61c24
--- /dev/null
+++ b/chat/templates/search.html
@@ -0,0 +1,37 @@
+{% extends "layout.html" %}
+{% block title %}Search - chat{% endblock %}
+{% block content %}
+
+
+
+
+{% if not query %}
+ {# Empty-state placeholder: the top-bar form submits to /search even
+ with no input, so this page must render cleanly with no query. #}
+ Enter a query to search memories across all chats.
+{% elif not results %}
+ No matches for “{{ query }}”.
+{% else %}
+
+{% endif %}
+{% endblock %}
diff --git a/chat/web/search.py b/chat/web/search.py
new file mode 100644
index 0000000..51d75ea
--- /dev/null
+++ b/chat/web/search.py
@@ -0,0 +1,92 @@
+"""T100 (Phase 4): cross-chat search UX route.
+
+Wraps T93's :func:`chat.services.cross_chat_search.search_all_memories`
+in a small read-only HTML surface so the top-bar search input has
+somewhere to land. The route does no filtering of its own beyond the
+empty-query fast-path that T93 already implements; ranking, owner
+scope, and witness scope all live in the service layer.
+
+For each match we hydrate just enough metadata to render a row:
+* the owner bot's display name (so users see "BOTA" not "bot_a"),
+* the originating ``chat_id`` (the link target — there's no per-turn
+ anchor today because memories don't carry an ``event_id`` column,
+ so we deep-link to the chat as a whole),
+* the originating scene title when one exists,
+* and the ``pov_summary`` itself.
+
+We deliberately keep this module synchronous and template-only — no
+HTMX swaps, no JSON API — because the search box is a "leave the
+current chat to look something up" surface, not an inline drawer.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+from chat.services.cross_chat_search import search_all_memories
+from chat.state.entities import get_bot
+from chat.state.world import get_chat, get_scene
+from chat.web.bots import get_conn
+
+TEMPLATES = Jinja2Templates(
+ directory=str(Path(__file__).resolve().parent.parent / "templates")
+)
+
+router = APIRouter()
+
+
+@router.get("/search", response_class=HTMLResponse)
+async def search(request: Request, q: str = "", conn=Depends(get_conn)):
+ """Render ``search.html`` with up to 50 cross-chat FTS matches.
+
+ ``q`` is intentionally allowed to be empty — that path renders the
+ page's "enter a query" placeholder rather than a 400, because the
+ top-bar form submits to this URL even with an empty input. T93's
+ service short-circuits whitespace-only queries to ``[]`` so there
+ is no FTS5 ``MATCH ''`` syntax error to guard against here.
+ """
+ raw_results = search_all_memories(conn, query=q, k=50) if q else []
+
+ # Hydrate display fields per row. We do this in the route (not the
+ # service) so the service stays a pure FTS shim that other UIs
+ # can reuse.
+ results = []
+ for row in raw_results:
+ bot = get_bot(conn, row["owner_id"])
+ chat = get_chat(conn, row["chat_id"])
+ scene = get_scene(conn, row["scene_id"]) if row["scene_id"] else None
+ results.append(
+ {
+ "memory_id": row["memory_id"],
+ "owner_id": row["owner_id"],
+ "owner_name": bot["name"] if bot else row["owner_id"],
+ "chat_id": row["chat_id"],
+ "chat_name": (
+ chat.get("narrative_anchor") if chat else None
+ ),
+ "scene_id": row["scene_id"],
+ # Scenes have no ``title`` column today; surface the
+ # ``started_at`` timestamp as a human-friendly label
+ # when a scene is set, otherwise leave it blank.
+ "scene_label": (
+ scene.get("started_at") if scene else None
+ ),
+ "pov_summary": row["pov_summary"],
+ "significance": row["significance"],
+ "ts": row["ts"],
+ }
+ )
+
+ return TEMPLATES.TemplateResponse(
+ request,
+ "search.html",
+ {
+ "query": q,
+ "results": results,
+ "active_nav": "search",
+ },
+ )
diff --git a/tests/test_search_ux.py b/tests/test_search_ux.py
new file mode 100644
index 0000000..7254549
--- /dev/null
+++ b/tests/test_search_ux.py
@@ -0,0 +1,135 @@
+"""T100 (Phase 4): cross-chat search UX (top-bar + results page).
+
+Verifies the FastAPI ``/search`` route that wraps T93's
+``search_all_memories`` service:
+
+* ``/search?q=...`` returns 200 + an HTML page that lists matches drawn
+ from MULTIPLE chats (not just the current one) and links each result
+ back to ``/chats/{chat_id}``.
+* ``/search`` with no query renders the page in its empty state with a
+ "enter a query" placeholder and no result rows (avoids hitting the
+ FTS index with an invalid empty MATCH).
+* Result links navigate to the originating chat so users can pick up
+ the thread where the memory came from.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+
+from chat.app import app
+from chat.db.connection import open_db
+from chat.eventlog.log import append_event
+from chat.eventlog.projector import project
+import chat.state.memory # noqa: F401 (registers memory_written handler)
+
+
+@pytest.fixture
+def client(tmp_path, monkeypatch):
+ config_path = tmp_path / "config.toml"
+ config_path.write_text('featherless_api_key = "test"\n')
+ monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path))
+ monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db"))
+ with TestClient(app) as c:
+ yield c
+
+
+def _seed_two_chats_with_memories(db_path: Path) -> None:
+ """Seed: a ``you_entity``, two bots, two chats, and one ``rabbit``
+ memory per chat. Two-chat seeding lets the cross-chat assertion
+ actually distinguish "both chats appear" from "only the current
+ one does"."""
+ with open_db(db_path) as conn:
+ append_event(
+ conn,
+ kind="you_authored",
+ payload={"name": "Me", "pronouns": "", "persona": ""},
+ )
+ for bot_id, chat_id in (("bot_a", "chat_a"), ("bot_b", "chat_b")):
+ append_event(
+ conn,
+ kind="bot_authored",
+ payload={
+ "id": bot_id,
+ "name": bot_id.upper(),
+ "persona": "thoughtful",
+ "voice_samples": [],
+ "traits": [],
+ "backstory": "",
+ "initial_relationship_to_you": "friend",
+ "kickoff_prose": "kickoff",
+ },
+ )
+ append_event(
+ conn,
+ kind="chat_created",
+ payload={
+ "id": chat_id,
+ "host_bot_id": bot_id,
+ "initial_time": "2026-04-26T20:00:00+00:00",
+ "narrative_anchor": "Day 1",
+ "weather": "",
+ },
+ )
+ append_event(
+ conn,
+ kind="memory_written",
+ payload={
+ "owner_id": bot_id,
+ "chat_id": chat_id,
+ "pov_summary": f"the rabbit darted across {chat_id}",
+ "witness_you": 1,
+ "witness_host": 1,
+ "witness_guest": 0,
+ "source": "direct",
+ "reliability": 1.0,
+ "significance": 1,
+ "pinned": 0,
+ "auto_pinned": 0,
+ },
+ )
+ project(conn)
+
+
+def test_search_returns_results_from_multiple_chats(client, tmp_path):
+ """A single ``/search?q=rabbit`` must surface matches from BOTH
+ chats — the whole point of the cross-chat search box is that it
+ isn't owner-scoped."""
+ _seed_two_chats_with_memories(tmp_path / "test.db")
+ resp = client.get("/search?q=rabbit")
+ assert resp.status_code == 200
+ body = resp.text
+ # Both chats' memory snippets must appear in the rendered page.
+ assert "chat_a" in body
+ assert "chat_b" in body
+ assert "rabbit" in body.lower()
+
+
+def test_empty_query_renders_placeholder_not_results(client, tmp_path):
+ """``/search`` with no query renders the page in its empty state.
+
+ The placeholder copy is a contract with the user — they should see
+ "enter a query" rather than an empty result list that looks like a
+ no-match. Also: the FTS short-circuit means there are no result
+ rows to leak into the body."""
+ _seed_two_chats_with_memories(tmp_path / "test.db")
+ resp = client.get("/search")
+ assert resp.status_code == 200
+ body = resp.text.lower()
+ assert "enter a query" in body
+ # Seeded "rabbit" memories must NOT appear: empty query => no results.
+ assert "the rabbit darted" not in resp.text
+
+
+def test_result_links_navigate_to_chat(client, tmp_path):
+ """Each result links back to its originating chat so the user can
+ reopen the thread where the memory was first witnessed."""
+ _seed_two_chats_with_memories(tmp_path / "test.db")
+ resp = client.get("/search?q=rabbit")
+ assert resp.status_code == 200
+ # The link target is chat-level (memories don't carry an event_id
+ # column today, so we don't deep-link to a specific turn).
+ assert 'href="/chats/chat_a"' in resp.text