diff --git a/bookmark/api/routes.py b/bookmark/api/routes.py
new file mode 100644
index 0000000..eabc9c9
--- /dev/null
+++ b/bookmark/api/routes.py
@@ -0,0 +1,47 @@
+import cv2
+from fastapi import APIRouter, Request, Response
+
+router = APIRouter()
+
+
+@router.get("/api/session-state")
+def get_session_state(request: Request):
+ session = request.app.state.session
+ return {"state": session.state.value, "book_id": session.current_book_id}
+
+
+@router.post("/api/observe")
+def observe(request: Request):
+ session = request.app.state.session
+ camera = request.app.state.camera
+ frame = camera.read_frame()
+ request.app.state.latest_frame = frame
+ fired = session.observe_frame(frame)
+ return {"fired": fired, "state": session.state.value, "book_id": session.current_book_id}
+
+
+@router.post("/api/manual-trigger")
+def manual_trigger(request: Request):
+ session = request.app.state.session
+ if not hasattr(session.strategy, "trigger"):
+ return Response(status_code=400, content="Active strategy does not support manual trigger")
+ session.strategy.trigger()
+ return {"ok": True}
+
+
+@router.post("/api/next-book")
+def next_book(request: Request):
+ session = request.app.state.session
+ session.start_next_book()
+ return {"state": session.state.value}
+
+
+@router.get("/api/preview.jpg")
+def preview(request: Request):
+ frame = request.app.state.latest_frame
+ if frame is None:
+ return Response(status_code=404, content="No frame captured yet")
+ ok, buffer = cv2.imencode(".jpg", frame)
+ if not ok:
+ return Response(status_code=500, content="Failed to encode preview frame")
+ return Response(content=buffer.tobytes(), media_type="image/jpeg")
diff --git a/bookmark/main.py b/bookmark/main.py
new file mode 100644
index 0000000..3607630
--- /dev/null
+++ b/bookmark/main.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+
+from .api.routes import router
+
+
+def create_app(session, camera) -> FastAPI:
+ app = FastAPI()
+ app.state.session = session
+ app.state.camera = camera
+ app.state.latest_frame = None
+ app.include_router(router)
+ static_dir = Path(__file__).parent / "static"
+ app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
+ return app
diff --git a/bookmark/static/index.html b/bookmark/static/index.html
new file mode 100644
index 0000000..9bb88d0
--- /dev/null
+++ b/bookmark/static/index.html
@@ -0,0 +1,5 @@
+
+
+
Books in Hand — Intake
+Books in Hand — Intake
+
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..876dec9
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,83 @@
+import numpy as np
+from fastapi.testclient import TestClient
+
+from bookmark.capture_strategies.manual_button import ManualButtonStrategy
+from bookmark.catalogue import Catalogue
+from bookmark.main import create_app
+from bookmark.session import IntakeSession
+from bookmark.sizing import MarkerRegion
+
+
+def make_reference(size=100, color=200):
+ return np.full((size, size, 3), color, dtype=np.uint8)
+
+
+def make_book_frame(size=100, ref_color=200, book_color=50, box=(20, 20, 40, 40)):
+ frame = np.full((size, size, 3), ref_color, dtype=np.uint8)
+ x, y, w, h = box
+ frame[y:y + h, x:x + w] = book_color
+ return frame
+
+
+class FakeCamera:
+ def __init__(self, frame: np.ndarray) -> None:
+ self.frame = frame
+
+ def read_frame(self) -> np.ndarray:
+ return self.frame
+
+
+def make_client(tmp_path):
+ catalogue = Catalogue(tmp_path / "catalogue.csv")
+ strategy = ManualButtonStrategy()
+ reference = make_reference()
+ markers = [MarkerRegion(name="small_boundary", bucket="small", x=0, y=0, width=10, height=10)]
+ session = IntakeSession(
+ catalogue=catalogue,
+ strategy=strategy,
+ reference_frame=reference,
+ markers=markers,
+ captures_dir=tmp_path / "captures",
+ )
+ camera = FakeCamera(reference)
+ app = create_app(session, camera)
+ return TestClient(app), session, camera
+
+
+def test_session_state_starts_waiting_front(tmp_path):
+ client, _, _ = make_client(tmp_path)
+ response = client.get("/api/session-state")
+ assert response.status_code == 200
+ assert response.json()["state"] == "waiting_front"
+
+
+def test_observe_without_trigger_does_not_fire(tmp_path):
+ client, _, _ = make_client(tmp_path)
+ response = client.post("/api/observe")
+ assert response.status_code == 200
+ assert response.json()["fired"] is False
+
+
+def test_manual_trigger_then_observe_captures_front(tmp_path):
+ client, _, camera = make_client(tmp_path)
+ camera.frame = make_book_frame()
+ client.post("/api/manual-trigger")
+ response = client.post("/api/observe")
+ body = response.json()
+ assert body["fired"] is True
+ assert body["state"] == "waiting_back"
+ assert body["book_id"] is not None
+
+
+def test_preview_returns_404_before_any_observe(tmp_path):
+ client, _, _ = make_client(tmp_path)
+ response = client.get("/api/preview.jpg")
+ assert response.status_code == 404
+
+
+def test_preview_returns_jpeg_after_observe(tmp_path):
+ client, _, _ = make_client(tmp_path)
+ client.post("/api/observe")
+ response = client.get("/api/preview.jpg")
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "image/jpeg"