diff --git a/chat/app.py b/chat/app.py
index 82e49ad..4251290 100644
--- a/chat/app.py
+++ b/chat/app.py
@@ -15,6 +15,7 @@ import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
from chat.web.bots import router as bots_router
+from chat.web.kickoff import router as kickoff_router
from chat.web.settings import router as settings_router
@@ -33,6 +34,7 @@ STATIC_DIR = Path(__file__).resolve().parent / "static"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(bots_router)
+app.include_router(kickoff_router)
app.include_router(settings_router)
diff --git a/chat/templates/kickoff_confirm.html b/chat/templates/kickoff_confirm.html
new file mode 100644
index 0000000..d830f39
--- /dev/null
+++ b/chat/templates/kickoff_confirm.html
@@ -0,0 +1,118 @@
+{% extends "base.html" %}
+{% block title %}Confirm kickoff - chat{% endblock %}
+{% block content %}
+
Confirm kickoff
+Review and edit the parsed opening scene for {{ values.bot_name }}, then confirm to start the chat.
+
+
+{% endblock %}
diff --git a/chat/web/kickoff.py b/chat/web/kickoff.py
new file mode 100644
index 0000000..4edfba9
--- /dev/null
+++ b/chat/web/kickoff.py
@@ -0,0 +1,284 @@
+"""Kickoff parse-and-confirm flow.
+
+After a bot is authored, the user lands on ``/bots//kickoff``. We call the
+LLM-backed ``parse_kickoff`` to extract a structured opening scene from the
+authored prose and render it as an editable form. On submit, the (possibly
+edited) values are turned into a sequence of events that initialize the chat,
+its container, the participants' activities, an open scene, and a seed edge.
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, Form, HTTPException, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+
+from chat.eventlog.log import append_event
+from chat.eventlog.projector import project
+from chat.llm.client import LLMClient
+from chat.services.kickoff import parse_kickoff
+from chat.state.entities import get_bot, get_you
+from chat.web.bots import get_conn
+
+TEMPLATES = Jinja2Templates(
+ directory=str(Path(__file__).resolve().parent.parent / "templates")
+)
+
+router = APIRouter()
+
+
+def get_llm_client(request: Request) -> LLMClient:
+ """Production LLM client. Tests override this via ``app.dependency_overrides``."""
+ settings = request.app.state.settings
+ from chat.llm.featherless import FeatherlessClient
+
+ return FeatherlessClient(
+ api_key=settings.featherless_api_key,
+ base_url=settings.featherless_base_url,
+ )
+
+
+def _parse_holding(text: str) -> list[str]:
+ if not text or not text.strip():
+ return []
+ return [p.strip() for p in text.split(",") if p.strip()]
+
+
+def _parse_facts(text: str) -> list[str]:
+ if not text or not text.strip():
+ return []
+ return [line.strip() for line in text.splitlines() if line.strip()]
+
+
+def _parse_properties(text: str) -> dict:
+ """Parse the container_properties textarea as JSON.
+
+ Returns ``{}`` on invalid JSON rather than raising — the form is editable
+ and a bad value should not block the user from confirming the rest.
+ """
+ if not text or not text.strip():
+ return {}
+ try:
+ loaded = json.loads(text)
+ return loaded if isinstance(loaded, dict) else {}
+ except (json.JSONDecodeError, ValueError):
+ return {}
+
+
+@router.get("/bots/{bot_id}/kickoff", response_class=HTMLResponse)
+async def kickoff_get(
+ bot_id: str,
+ request: Request,
+ conn=Depends(get_conn),
+ llm=Depends(get_llm_client),
+):
+ bot = get_bot(conn, bot_id)
+ if bot is None:
+ raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}")
+
+ you = get_you(conn)
+ you_name = you["name"] if you else "You"
+
+ settings = request.app.state.settings
+ parsed = await parse_kickoff(
+ llm,
+ model=settings.classifier_model,
+ bot_name=bot["name"],
+ bot_persona=bot["persona"],
+ initial_relationship_to_you=bot.get("initial_relationship_to_you", ""),
+ kickoff_prose=bot.get("kickoff_prose", ""),
+ you_name=you_name,
+ timeout_s=settings.classifier_timeout_s,
+ )
+
+ # Render values onto the form. ``container_properties`` is shown as JSON;
+ # ``holding`` lists are rendered as comma-separated text; the seed
+ # knowledge facts are rendered one-per-line.
+ values = {
+ "bot_id": bot_id,
+ "bot_name": bot["name"],
+ "container_name": parsed.container_name,
+ "container_type": parsed.container_type,
+ "container_properties": json.dumps(parsed.container_properties, indent=2),
+ "initial_time_iso": parsed.initial_time_iso,
+ "you_activity_posture": parsed.you_activity.posture,
+ "you_activity_action_verb": parsed.you_activity.action_verb,
+ "you_activity_action_interruptible": parsed.you_activity.action_interruptible,
+ "you_activity_action_required_attention": parsed.you_activity.action_required_attention,
+ "you_activity_action_expected_duration": parsed.you_activity.action_expected_duration,
+ "you_activity_attention": parsed.you_activity.attention,
+ "you_activity_holding": ", ".join(parsed.you_activity.holding),
+ "bot_activity_posture": parsed.bot_activity.posture,
+ "bot_activity_action_verb": parsed.bot_activity.action_verb,
+ "bot_activity_action_interruptible": parsed.bot_activity.action_interruptible,
+ "bot_activity_action_required_attention": parsed.bot_activity.action_required_attention,
+ "bot_activity_action_expected_duration": parsed.bot_activity.action_expected_duration,
+ "bot_activity_attention": parsed.bot_activity.attention,
+ "bot_activity_holding": ", ".join(parsed.bot_activity.holding),
+ "edge_seed_summary": parsed.edge_seed_summary,
+ "edge_seed_knowledge_facts": "\n".join(parsed.edge_seed_knowledge_facts),
+ }
+ return TEMPLATES.TemplateResponse(request, "kickoff_confirm.html", {"values": values})
+
+
+@router.post("/bots/{bot_id}/kickoff")
+async def kickoff_post(
+ bot_id: str,
+ request: Request,
+ container_name: str = Form(""),
+ container_type: str = Form(""),
+ container_properties: str = Form(""),
+ initial_time_iso: str = Form(""),
+ you_activity_posture: str = Form(""),
+ you_activity_action_verb: str = Form(""),
+ you_activity_action_interruptible: str = Form(""),
+ you_activity_action_required_attention: str = Form("low"),
+ you_activity_action_expected_duration: str = Form(""),
+ you_activity_attention: str = Form(""),
+ you_activity_holding: str = Form(""),
+ bot_activity_posture: str = Form(""),
+ bot_activity_action_verb: str = Form(""),
+ bot_activity_action_interruptible: str = Form(""),
+ bot_activity_action_required_attention: str = Form("low"),
+ bot_activity_action_expected_duration: str = Form(""),
+ bot_activity_attention: str = Form(""),
+ bot_activity_holding: str = Form(""),
+ edge_seed_summary: str = Form(""),
+ edge_seed_knowledge_facts: str = Form(""),
+ conn=Depends(get_conn),
+):
+ bot = get_bot(conn, bot_id)
+ if bot is None:
+ raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}")
+
+ # Loose ISO 8601 validation. ``datetime.fromisoformat`` accepts the offset
+ # form ``2026-04-26T20:00:00+00:00`` we use; reject anything it can't parse.
+ if initial_time_iso.strip():
+ try:
+ datetime.fromisoformat(initial_time_iso.strip())
+ except ValueError:
+ raise HTTPException(
+ status_code=400,
+ detail=f"invalid initial_time_iso: {initial_time_iso!r}",
+ )
+
+ chat_id = f"chat_{bot_id}"
+
+ # Predict the next container id so we can reference it from later events
+ # without needing a mid-flow projection. Containers use AUTOINCREMENT-style
+ # rowid, so MAX(id)+1 is safe within this single-writer transaction.
+ next_container_row = conn.execute(
+ "SELECT COALESCE(MAX(id), 0) + 1 FROM containers"
+ ).fetchone()
+ container_id = next_container_row[0]
+
+ # 1. chat_created
+ append_event(
+ conn,
+ kind="chat_created",
+ payload={
+ "id": chat_id,
+ "host_bot_id": bot_id,
+ "initial_time": initial_time_iso,
+ "narrative_anchor": "Day 1",
+ "weather": "",
+ },
+ )
+
+ # 2. container_created
+ append_event(
+ conn,
+ kind="container_created",
+ payload={
+ "chat_id": chat_id,
+ "name": container_name,
+ "type": container_type,
+ "properties": _parse_properties(container_properties),
+ "parent_id": None,
+ },
+ )
+
+ you_interruptible = bool(you_activity_action_interruptible)
+ bot_interruptible = bool(bot_activity_action_interruptible)
+
+ # 3. activity_change for "you"
+ append_event(
+ conn,
+ kind="activity_change",
+ payload={
+ "entity_id": "you",
+ "container_id": container_id,
+ "posture": you_activity_posture,
+ "action": {
+ "verb": you_activity_action_verb,
+ "interruptible": you_interruptible,
+ "required_attention": you_activity_action_required_attention,
+ "expected_duration": you_activity_action_expected_duration,
+ "started_at": initial_time_iso,
+ },
+ "attention": you_activity_attention,
+ "holding": _parse_holding(you_activity_holding),
+ "status": {},
+ },
+ )
+
+ # 4. activity_change for bot
+ append_event(
+ conn,
+ kind="activity_change",
+ payload={
+ "entity_id": bot_id,
+ "container_id": container_id,
+ "posture": bot_activity_posture,
+ "action": {
+ "verb": bot_activity_action_verb,
+ "interruptible": bot_interruptible,
+ "required_attention": bot_activity_action_required_attention,
+ "expected_duration": bot_activity_action_expected_duration,
+ "started_at": initial_time_iso,
+ },
+ "attention": bot_activity_attention,
+ "holding": _parse_holding(bot_activity_holding),
+ "status": {},
+ },
+ )
+
+ # 5. scene_opened
+ append_event(
+ conn,
+ kind="scene_opened",
+ payload={
+ "chat_id": chat_id,
+ "container_id": container_id,
+ "started_at": initial_time_iso,
+ "participants": ["you", bot_id],
+ },
+ )
+
+ # 6. edge_update (seed). The seed summary is preserved as the first
+ # knowledge fact prefixed with ``[summary] `` — proper summary writes happen
+ # at scene-close (T27).
+ facts = _parse_facts(edge_seed_knowledge_facts)
+ if edge_seed_summary.strip():
+ facts.insert(0, f"[summary] {edge_seed_summary.strip()}")
+ append_event(
+ conn,
+ kind="edge_update",
+ payload={
+ "source_id": bot_id,
+ "target_id": "you",
+ "chat_id": chat_id,
+ "knowledge_facts": facts,
+ },
+ )
+
+ # Project all events at once. ``bot_authored`` (already in log from prior
+ # POST) is idempotent (INSERT OR REPLACE); the new events project cleanly
+ # because they're being applied for the first time.
+ project(conn)
+
+ return RedirectResponse(url=f"/chats/{chat_id}", status_code=303)
diff --git a/tests/test_kickoff_confirm.py b/tests/test_kickoff_confirm.py
new file mode 100644
index 0000000..ef308cb
--- /dev/null
+++ b/tests/test_kickoff_confirm.py
@@ -0,0 +1,212 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+
+from chat.app import app
+from chat.eventlog.log import append_event
+from chat.eventlog.projector import project
+from chat.llm.mock import MockLLMClient
+
+
+CANNED_PARSE = {
+ "container_name": "office",
+ "container_type": "workplace",
+ "container_properties": {
+ "public": True,
+ "moving": False,
+ "audible_range": "normal",
+ },
+ "you_activity": {
+ "posture": "sitting",
+ "action_verb": "working late",
+ "action_interruptible": True,
+ "action_required_attention": "medium",
+ "action_expected_duration": "an hour",
+ "attention": "the screen",
+ "holding": [],
+ },
+ "bot_activity": {
+ "posture": "sitting",
+ "action_verb": "writing email",
+ "action_interruptible": True,
+ "action_required_attention": "medium",
+ "action_expected_duration": "a few minutes",
+ "attention": "her keyboard",
+ "holding": [],
+ },
+ "initial_time_iso": "2026-04-26T20:00:00+00:00",
+ "edge_seed_summary": "BotA is your coworker.",
+ "edge_seed_knowledge_facts": [
+ "coworker",
+ "they sometimes stay late together",
+ ],
+}
+
+
+@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"))
+
+ # Import after env is set so dependency lookup uses MockLLMClient.
+ from chat.web.kickoff import get_llm_client
+
+ mock = MockLLMClient(canned=[json.dumps(CANNED_PARSE)])
+ app.dependency_overrides[get_llm_client] = lambda: mock
+
+ with TestClient(app) as c:
+ c.mock_llm = mock # type: ignore[attr-defined]
+ yield c
+
+ app.dependency_overrides.clear()
+
+
+def _author_bot(db_path: Path, bot_id: str = "bot_a") -> None:
+ from chat.db.connection import open_db
+
+ with open_db(db_path) as conn:
+ append_event(
+ conn,
+ kind="bot_authored",
+ payload={
+ "id": bot_id,
+ "name": "BotA",
+ "persona": "thoughtful, observant",
+ "voice_samples": [],
+ "traits": ["shy"],
+ "backstory": "",
+ "initial_relationship_to_you": "coworker",
+ "kickoff_prose": "you stay late at the office; she's there too",
+ },
+ )
+ project(conn)
+
+
+def test_get_kickoff_404_when_bot_missing(client):
+ response = client.get("/bots/no_such_bot/kickoff")
+ assert response.status_code == 404
+
+
+def test_get_kickoff_renders_parsed_form(client, tmp_path):
+ _author_bot(tmp_path / "test.db", "bot_a")
+ response = client.get("/bots/bot_a/kickoff")
+ assert response.status_code == 200
+ body = response.text
+ assert "office" in body
+ assert "sitting" in body
+ assert "working late" in body
+ # Mock was consumed once.
+ assert len(client.mock_llm._canned) == 0
+
+
+def test_post_kickoff_creates_chat_and_redirects(client, tmp_path):
+ _author_bot(tmp_path / "test.db", "bot_a")
+
+ form_data = {
+ "container_name": "office",
+ "container_type": "workplace",
+ "container_properties": json.dumps(CANNED_PARSE["container_properties"]),
+ "initial_time_iso": "2026-04-26T20:00:00+00:00",
+ "you_activity_posture": "sitting",
+ "you_activity_action_verb": "working late",
+ "you_activity_action_interruptible": "on",
+ "you_activity_action_required_attention": "medium",
+ "you_activity_action_expected_duration": "an hour",
+ "you_activity_attention": "the screen",
+ "you_activity_holding": "",
+ "bot_activity_posture": "sitting",
+ "bot_activity_action_verb": "writing email",
+ "bot_activity_action_interruptible": "on",
+ "bot_activity_action_required_attention": "medium",
+ "bot_activity_action_expected_duration": "a few minutes",
+ "bot_activity_attention": "her keyboard",
+ "bot_activity_holding": "",
+ "edge_seed_summary": "BotA is your coworker.",
+ "edge_seed_knowledge_facts": "coworker\nthey sometimes stay late together",
+ }
+ response = client.post(
+ "/bots/bot_a/kickoff",
+ data=form_data,
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ assert response.headers["location"] == "/chats/chat_bot_a"
+
+ from chat.db.connection import open_db
+ from chat.state.world import (
+ active_scene,
+ find_container,
+ get_activity,
+ get_chat,
+ )
+ from chat.state.edges import get_edge
+
+ with open_db(tmp_path / "test.db") as conn:
+ chat = get_chat(conn, "chat_bot_a")
+ assert chat is not None
+ assert chat["host_bot_id"] == "bot_a"
+ assert chat["time"] == "2026-04-26T20:00:00+00:00"
+
+ container = find_container(conn, "chat_bot_a", "office")
+ assert container is not None
+ assert container["type"] == "workplace"
+
+ you_act = get_activity(conn, "you")
+ assert you_act is not None
+ assert you_act["posture"] == "sitting"
+ assert you_act["action"]["verb"] == "working late"
+
+ bot_act = get_activity(conn, "bot_a")
+ assert bot_act is not None
+ assert bot_act["posture"] == "sitting"
+ assert bot_act["action"]["verb"] == "writing email"
+
+ scene = active_scene(conn, "chat_bot_a")
+ assert scene is not None
+ assert scene["ended_at"] is None
+ assert "you" in scene["participants"]
+ assert "bot_a" in scene["participants"]
+
+ edge = get_edge(conn, "bot_a", "you")
+ assert edge is not None
+ knowledge = edge["knowledge"]
+ assert "coworker" in knowledge
+ assert "they sometimes stay late together" in knowledge
+ # The seed summary should appear somewhere in knowledge as a v1 compromise.
+ assert any("BotA is your coworker" in k for k in knowledge)
+
+
+def test_post_kickoff_404_when_bot_missing(client):
+ response = client.post(
+ "/bots/no_such/kickoff",
+ data={
+ "container_name": "office",
+ "container_type": "workplace",
+ "container_properties": "{}",
+ "initial_time_iso": "2026-04-26T20:00:00+00:00",
+ "you_activity_posture": "",
+ "you_activity_action_verb": "",
+ "you_activity_action_interruptible": "on",
+ "you_activity_action_required_attention": "low",
+ "you_activity_action_expected_duration": "",
+ "you_activity_attention": "",
+ "you_activity_holding": "",
+ "bot_activity_posture": "",
+ "bot_activity_action_verb": "",
+ "bot_activity_action_interruptible": "on",
+ "bot_activity_action_required_attention": "low",
+ "bot_activity_action_expected_duration": "",
+ "bot_activity_attention": "",
+ "bot_activity_holding": "",
+ "edge_seed_summary": "",
+ "edge_seed_knowledge_facts": "",
+ },
+ follow_redirects=False,
+ )
+ assert response.status_code == 404