diff --git a/chat/services/reset.py b/chat/services/reset.py
new file mode 100644
index 0000000..a8e573d
--- /dev/null
+++ b/chat/services/reset.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from sqlite3 import Connection
+
+from chat.eventlog.log import append_and_apply
+from chat.state.entities import get_bot
+
+
+def reset_bot(conn: Connection, bot_id: str, *, confirm_name: str) -> None:
+ """Reset a bot's runtime state via a ``bot_reset`` event.
+
+ Validates that ``confirm_name`` matches the bot's stored ``name``
+ exactly (case-sensitive, no trim). Raises:
+
+ - ``ValueError("bot {bot_id} not found")`` when the bot is missing.
+ - ``ValueError("confirm_name does not match bot name")`` on mismatch.
+ """
+ bot = get_bot(conn, bot_id)
+ if bot is None:
+ raise ValueError(f"bot {bot_id} not found")
+ if confirm_name != bot["name"]:
+ raise ValueError("confirm_name does not match bot name")
+ append_and_apply(conn, kind="bot_reset", payload={"bot_id": bot_id})
diff --git a/chat/state/entities.py b/chat/state/entities.py
index 57d0cc3..df14565 100644
--- a/chat/state/entities.py
+++ b/chat/state/entities.py
@@ -31,6 +31,46 @@ def _apply_you_authored(conn: Connection, e: Event) -> None:
)
+@on("bot_reset")
+def _apply_bot_reset(conn: Connection, e: Event) -> None:
+ """Purge per-bot runtime state while preserving the bot's identity row.
+
+ Wipes chats hosted by this bot (with cascading chat-scoped tables),
+ memories owned by this bot, edges involving this bot, and the bot's own
+ activity row. The ``bots`` row itself is preserved so identity,
+ initial-relationship, and kickoff prose remain authored.
+ """
+ bot_id = e.payload["bot_id"]
+
+ chat_ids = [
+ row[0]
+ for row in conn.execute(
+ "SELECT id FROM chats WHERE host_bot_id = ?", (bot_id,)
+ ).fetchall()
+ ]
+ for chat_id in chat_ids:
+ conn.execute("DELETE FROM scenes WHERE chat_id = ?", (chat_id,))
+ conn.execute("DELETE FROM containers WHERE chat_id = ?", (chat_id,))
+ conn.execute("DELETE FROM chat_state WHERE chat_id = ?", (chat_id,))
+ conn.execute("DELETE FROM chats WHERE id = ?", (chat_id,))
+
+ # Activity for this bot's entity row (independent of chat_id since the
+ # ``activity`` table is keyed on entity_id).
+ conn.execute("DELETE FROM activity WHERE entity_id = ?", (bot_id,))
+
+ # Memories authored by this bot.
+ conn.execute("DELETE FROM memories WHERE owner_id = ?", (bot_id,))
+
+ # Edges in either direction involving this bot.
+ conn.execute(
+ "DELETE FROM edges WHERE source_id = ? OR target_id = ?",
+ (bot_id, bot_id),
+ )
+ # NOTE: bots row itself is preserved (identity, kickoff_prose intact).
+ # NOTE: "you" activity (entity_id="you") may linger from a deleted chat;
+ # acceptable for v1 — Phase 1.5 cleanup if needed.
+
+
def get_bot(conn: Connection, bot_id: str) -> dict | None:
row = conn.execute("SELECT * FROM bots WHERE id = ?", (bot_id,)).fetchone()
if not row:
diff --git a/chat/templates/bot_list.html b/chat/templates/bot_list.html
index 7a2a65b..247d886 100644
--- a/chat/templates/bot_list.html
+++ b/chat/templates/bot_list.html
@@ -8,7 +8,18 @@
{% if bots %}
{% else %}
diff --git a/chat/web/bots.py b/chat/web/bots.py
index d06ba7b..d9666f3 100644
--- a/chat/web/bots.py
+++ b/chat/web/bots.py
@@ -118,3 +118,22 @@ async def bot_create(
append_event(conn, kind="bot_authored", payload=payload)
project(conn)
return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303)
+
+
+@router.post("/bots/{bot_id}/reset")
+async def reset_bot_route(
+ bot_id: str,
+ request: Request,
+ confirm_name: str = Form(""),
+ conn=Depends(get_conn),
+):
+ from chat.services.reset import reset_bot
+
+ try:
+ reset_bot(conn, bot_id, confirm_name=confirm_name)
+ except ValueError as e:
+ msg = str(e).lower()
+ if "not found" in msg:
+ raise HTTPException(status_code=404, detail=str(e))
+ raise HTTPException(status_code=400, detail=str(e))
+ return RedirectResponse(url="/bots", status_code=303)
diff --git a/tests/test_reset.py b/tests/test_reset.py
new file mode 100644
index 0000000..7d3c1b8
--- /dev/null
+++ b/tests/test_reset.py
@@ -0,0 +1,185 @@
+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
+
+
+@pytest.fixture
+def client(tmp_path, monkeypatch):
+ cfg = tmp_path / "config.toml"
+ cfg.write_text('featherless_api_key = "test"\n')
+ monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
+ db = tmp_path / "test.db"
+ monkeypatch.setenv("CHAT_DB_PATH", str(db))
+ with TestClient(app) as c:
+ if hasattr(app.state, "background_worker"):
+ app.state.background_worker.enabled = False
+ yield c
+
+
+def _seed_bot_with_state(db: Path) -> None:
+ """Seed a bot plus a chat, container, scene, edge, memory, and activity row."""
+ with open_db(db) as conn:
+ append_event(
+ conn,
+ kind="bot_authored",
+ payload={
+ "id": "bot_a",
+ "name": "BotA",
+ "persona": "thoughtful, observant",
+ "voice_samples": [],
+ "traits": ["shy"],
+ "backstory": "",
+ "initial_relationship_to_you": "coworker",
+ "kickoff_prose": "you stay late",
+ },
+ )
+ append_event(
+ conn,
+ kind="chat_created",
+ payload={
+ "id": "chat_bot_a",
+ "host_bot_id": "bot_a",
+ "initial_time": "2026-04-26T20:00:00+00:00",
+ "narrative_anchor": "Day 1",
+ "weather": "",
+ },
+ )
+ append_event(
+ conn,
+ kind="container_created",
+ payload={
+ "chat_id": "chat_bot_a",
+ "name": "office",
+ "type": "workplace",
+ "properties": {},
+ },
+ )
+ append_event(
+ conn,
+ kind="scene_opened",
+ payload={
+ "chat_id": "chat_bot_a",
+ "container_id": 1,
+ "started_at": "2026-04-26T20:00:00+00:00",
+ "participants": ["you", "bot_a"],
+ },
+ )
+ append_event(
+ conn,
+ kind="edge_update",
+ payload={
+ "source_id": "bot_a",
+ "target_id": "you",
+ "chat_id": "chat_bot_a",
+ "affinity_delta": 5,
+ "trust_delta": 2,
+ },
+ )
+ append_event(
+ conn,
+ kind="memory_written",
+ payload={
+ "owner_id": "bot_a",
+ "chat_id": "chat_bot_a",
+ "pov_summary": "Talked about her sister",
+ "witness_you": 1,
+ "witness_host": 1,
+ "witness_guest": 0,
+ "significance": 2,
+ },
+ )
+ append_event(
+ conn,
+ kind="activity_change",
+ payload={
+ "entity_id": "bot_a",
+ "posture": "sitting",
+ "action": {"verb": "writing"},
+ },
+ )
+ project(conn)
+
+
+def test_reset_purges_state_but_preserves_identity(client, tmp_path):
+ _seed_bot_with_state(tmp_path / "test.db")
+ response = client.post(
+ "/bots/bot_a/reset",
+ data={"confirm_name": "BotA"},
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ assert response.headers["location"] == "/bots"
+
+ with open_db(tmp_path / "test.db") as conn:
+ # Identity preserved.
+ bot = conn.execute(
+ "SELECT id, name, kickoff_prose, initial_relationship_to_you "
+ "FROM bots WHERE id = 'bot_a'"
+ ).fetchone()
+ assert bot is not None
+ assert bot[1] == "BotA"
+ assert bot[2] == "you stay late"
+ assert bot[3] == "coworker"
+
+ # State purged.
+ assert conn.execute(
+ "SELECT COUNT(*) FROM chats WHERE host_bot_id = 'bot_a'"
+ ).fetchone()[0] == 0
+ assert conn.execute(
+ "SELECT COUNT(*) FROM scenes WHERE chat_id = 'chat_bot_a'"
+ ).fetchone()[0] == 0
+ assert conn.execute(
+ "SELECT COUNT(*) FROM containers WHERE chat_id = 'chat_bot_a'"
+ ).fetchone()[0] == 0
+ assert conn.execute(
+ "SELECT COUNT(*) FROM chat_state WHERE chat_id = 'chat_bot_a'"
+ ).fetchone()[0] == 0
+ assert conn.execute(
+ "SELECT COUNT(*) FROM memories WHERE owner_id = 'bot_a'"
+ ).fetchone()[0] == 0
+ assert conn.execute(
+ "SELECT COUNT(*) FROM edges WHERE source_id = 'bot_a' OR target_id = 'bot_a'"
+ ).fetchone()[0] == 0
+ assert conn.execute(
+ "SELECT COUNT(*) FROM activity WHERE entity_id = 'bot_a'"
+ ).fetchone()[0] == 0
+
+ # Event log records the bot_reset event.
+ assert conn.execute(
+ "SELECT COUNT(*) FROM event_log WHERE kind = 'bot_reset'"
+ ).fetchone()[0] == 1
+
+
+def test_reset_400_when_confirm_name_mismatch(client, tmp_path):
+ _seed_bot_with_state(tmp_path / "test.db")
+ response = client.post(
+ "/bots/bot_a/reset",
+ data={"confirm_name": "WrongName"},
+ follow_redirects=False,
+ )
+ assert response.status_code == 400
+
+
+def test_reset_404_when_bot_missing(client):
+ response = client.post(
+ "/bots/no_such/reset",
+ data={"confirm_name": "Anything"},
+ follow_redirects=False,
+ )
+ assert response.status_code == 404
+
+
+def test_bot_list_renders_reset_form(client, tmp_path):
+ _seed_bot_with_state(tmp_path / "test.db")
+ response = client.get("/bots")
+ assert response.status_code == 200
+ assert "Reset" in response.text
+ assert "confirm_name" in response.text