diff --git a/backend/src/spicebook/engine/__init__.py b/backend/src/spicebook/engine/__init__.py
index e69de29..894c30f 100644
--- a/backend/src/spicebook/engine/__init__.py
+++ b/backend/src/spicebook/engine/__init__.py
@@ -0,0 +1,13 @@
+"""SPICE simulation engine registry."""
+
+from fastapi import HTTPException
+
+from spicebook.engine.base import SpiceEngine
+from spicebook.engine.ngspice import NgspiceEngine
+
+
+def get_engine(engine_name: str) -> SpiceEngine:
+ """Resolve a simulation engine by name."""
+ if engine_name == "ngspice":
+ return NgspiceEngine()
+ raise HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'")
diff --git a/backend/src/spicebook/main.py b/backend/src/spicebook/main.py
index ca3b3fc..e01edd1 100644
--- a/backend/src/spicebook/main.py
+++ b/backend/src/spicebook/main.py
@@ -10,7 +10,7 @@ from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from spicebook.config import settings
-from spicebook.routers import notebooks, schematics, simulation, waveforms
+from spicebook.routers import compose, notebooks, schematics, simulation, waveforms
logger = logging.getLogger("spicebook")
@@ -59,6 +59,9 @@ def create_app() -> FastAPI:
)
return response
+ # compose MUST be registered before notebooks so that
+ # POST /api/notebooks/compose matches before {notebook_id}
+ application.include_router(compose.router)
application.include_router(notebooks.router)
application.include_router(schematics.router)
application.include_router(simulation.router)
diff --git a/backend/src/spicebook/models/notebook.py b/backend/src/spicebook/models/notebook.py
index 1eedeb7..066e843 100644
--- a/backend/src/spicebook/models/notebook.py
+++ b/backend/src/spicebook/models/notebook.py
@@ -67,3 +67,16 @@ class UpdateCellRequest(BaseModel):
class ReorderCellsRequest(BaseModel):
cell_ids: list[str] # Ordered list of all cell IDs
+
+
+class ComposeCellInput(BaseModel):
+ type: CellType
+ source: str = ""
+
+
+class ComposeNotebookRequest(BaseModel):
+ title: str = Field("Untitled Notebook", max_length=256)
+ engine: str = "ngspice"
+ tags: list[str] = Field(default_factory=list, max_length=20)
+ cells: list[ComposeCellInput] = Field(..., max_length=100)
+ run: bool = False
diff --git a/backend/src/spicebook/routers/compose.py b/backend/src/spicebook/routers/compose.py
new file mode 100644
index 0000000..1222d1f
--- /dev/null
+++ b/backend/src/spicebook/routers/compose.py
@@ -0,0 +1,94 @@
+"""Compose endpoint — create a fully-populated notebook in one call."""
+
+import logging
+import tempfile
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+
+from fastapi import APIRouter, HTTPException
+
+from spicebook.config import settings
+from spicebook.engine import get_engine
+from spicebook.models.notebook import (
+ Cell,
+ CellOutput,
+ CellType,
+ ComposeNotebookRequest,
+ Notebook,
+ NotebookMetadata,
+)
+from spicebook.models.simulation import SimulationResponse
+from spicebook.storage.filesystem import generate_notebook_id, save_notebook
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/notebooks", tags=["compose"])
+
+
+@router.post("/compose", status_code=201)
+async def compose_notebook(req: ComposeNotebookRequest):
+ """Create a notebook with pre-populated cells, optionally running SPICE cells."""
+ if not req.cells:
+ raise HTTPException(status_code=400, detail="At least one cell is required")
+
+ nb_id = generate_notebook_id(req.title)
+ now = datetime.now(timezone.utc).isoformat()
+
+ cells: list[Cell] = []
+ for item in req.cells:
+ cells.append(
+ Cell(
+ id=f"cell-{uuid.uuid4().hex[:12]}",
+ type=item.type,
+ source=item.source,
+ )
+ )
+
+ notebook = Notebook(
+ metadata=NotebookMetadata(
+ title=req.title,
+ engine=req.engine,
+ tags=req.tags,
+ created=now,
+ modified=now,
+ ),
+ cells=cells,
+ )
+
+ if req.run:
+ engine = get_engine(req.engine)
+ for cell in notebook.cells:
+ if cell.type != CellType.SPICE:
+ continue
+ if not cell.source.strip():
+ continue
+
+ try:
+ with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
+ result = await engine.run(cell.source, Path(tmpdir))
+ except Exception:
+ logger.exception("Simulation failed for cell %s", cell.id)
+ result = SimulationResponse(
+ success=False,
+ error="Internal simulation error",
+ elapsed_seconds=0.0,
+ )
+
+ run_ts = datetime.now(timezone.utc).isoformat()
+ cell.outputs.append(
+ CellOutput(
+ output_type="simulation_result" if result.success else "error",
+ data={
+ "success": result.success,
+ "waveform": result.waveform.model_dump() if result.waveform else None,
+ "log": result.log,
+ "error": result.error,
+ "elapsed_seconds": result.elapsed_seconds,
+ },
+ timestamp=run_ts,
+ )
+ )
+
+ save_notebook(settings.notebook_dir, nb_id, notebook)
+ return {"id": nb_id, **notebook.model_dump()}
diff --git a/backend/src/spicebook/routers/simulation.py b/backend/src/spicebook/routers/simulation.py
index 8833522..1c9b879 100644
--- a/backend/src/spicebook/routers/simulation.py
+++ b/backend/src/spicebook/routers/simulation.py
@@ -7,7 +7,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from spicebook.config import settings
-from spicebook.engine.ngspice import NgspiceEngine
+from spicebook.engine import get_engine
from spicebook.models.notebook import CellOutput, CellType
from spicebook.models.simulation import SimulationRequest, SimulationResponse
from spicebook.storage.filesystem import load_notebook, save_notebook
@@ -15,17 +15,10 @@ from spicebook.storage.filesystem import load_notebook, save_notebook
router = APIRouter(prefix="/api", tags=["simulation"])
-def _get_engine(engine_name: str) -> NgspiceEngine:
- """Resolve engine by name. Only ngspice is supported in Phase 1."""
- if engine_name == "ngspice":
- return NgspiceEngine()
- raise HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'")
-
-
@router.post("/simulate", response_model=SimulationResponse)
async def simulate(req: SimulationRequest):
"""Run a standalone SPICE simulation."""
- engine = _get_engine(req.engine)
+ engine = get_engine(req.engine)
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(req.netlist, Path(tmpdir))
@@ -61,7 +54,7 @@ async def run_cell(notebook_id: str, cell_id: str):
if not cell.source.strip():
raise HTTPException(status_code=400, detail="Cell source is empty")
- engine = _get_engine(nb.metadata.engine)
+ engine = get_engine(nb.metadata.engine)
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(cell.source, Path(tmpdir))
diff --git a/backend/src/spicebook/storage/filesystem.py b/backend/src/spicebook/storage/filesystem.py
index 476967f..0c264d0 100644
--- a/backend/src/spicebook/storage/filesystem.py
+++ b/backend/src/spicebook/storage/filesystem.py
@@ -33,7 +33,7 @@ def _slugify(text: str) -> str:
return slug or "notebook"
-def _generate_notebook_id(title: str) -> str:
+def generate_notebook_id(title: str) -> str:
"""Generate a notebook ID from title, with short UUID suffix for uniqueness."""
slug = _slugify(title)
short_uid = uuid.uuid4().hex[:8]
@@ -105,7 +105,7 @@ def save_notebook(directory: Path, notebook_id: str, notebook: Notebook) -> None
def create_notebook(directory: Path, title: str, engine: str = "ngspice") -> tuple[str, Notebook]:
"""Create a new notebook and save it. Returns (notebook_id, notebook)."""
- nb_id = _generate_notebook_id(title)
+ nb_id = generate_notebook_id(title)
now = _now_iso()
notebook = Notebook(
diff --git a/frontend/public/llms.txt b/frontend/public/llms.txt
new file mode 100644
index 0000000..f98c904
--- /dev/null
+++ b/frontend/public/llms.txt
@@ -0,0 +1,546 @@
+# SpiceBook
+
+> Notebook interface for SPICE circuit simulation. Create, edit, and run SPICE netlists in a cell-based notebook UI with waveform visualization and schematic generation. Powered by ngspice.
+
+Base URL: `https://spicebook.warehack.ing`
+
+## API Reference
+
+All endpoints accept and return JSON unless noted otherwise. Prefix all paths with the base URL.
+
+---
+
+### Notebooks
+
+#### List notebooks
+
+```
+GET /api/notebooks
+```
+
+**Response** `200`
+```json
+[
+ {
+ "id": "rc-low-pass-a1b2c3d4",
+ "title": "RC Low-Pass Filter",
+ "engine": "ngspice",
+ "tags": ["filter", "rc"],
+ "cell_count": 3,
+ "modified": "2026-02-13T18:30:00+00:00"
+ }
+]
+```
+
+#### Create notebook
+
+```
+POST /api/notebooks
+```
+
+**Request body**
+```json
+{
+ "title": "My Circuit",
+ "engine": "ngspice"
+}
+```
+
+Both fields are optional. Defaults: title = `"Untitled Notebook"`, engine = `"ngspice"`.
+
+**Response** `201`
+```json
+{
+ "id": "my-circuit-f8e2a91b",
+ "spicebook_version": "2026-02-13",
+ "metadata": {
+ "title": "My Circuit",
+ "engine": "ngspice",
+ "tags": [],
+ "created": "2026-02-13T18:30:00+00:00",
+ "modified": "2026-02-13T18:30:00+00:00"
+ },
+ "cells": [
+ {
+ "id": "cell-a1b2c3d4e5f6",
+ "type": "markdown",
+ "source": "# My Circuit\n\nAdd SPICE cells below to begin simulating.",
+ "outputs": []
+ }
+ ]
+}
+```
+
+#### Get notebook
+
+```
+GET /api/notebooks/{notebook_id}
+```
+
+**Response** `200` — Full `Notebook` object (same shape as create response, without `id` wrapper).
+
+#### Update notebook
+
+```
+PUT /api/notebooks/{notebook_id}
+```
+
+**Request body** — Full `Notebook` object (replaces entire notebook).
+
+**Response** `200` — The saved `Notebook`.
+
+#### Delete notebook
+
+```
+DELETE /api/notebooks/{notebook_id}
+```
+
+**Response** `204` — No content. Only user-created notebooks can be deleted.
+
+---
+
+### Cells
+
+Cells are ordered elements within a notebook. Each cell has a `type` (`markdown`, `spice`, `python`, `schematic`) and `source` (text content).
+
+#### Add cell
+
+```
+POST /api/notebooks/{notebook_id}/cells
+```
+
+**Request body**
+```json
+{
+ "type": "spice",
+ "source": "V1 1 0 DC 5\nR1 1 0 1k\n.op\n.end",
+ "after_cell_id": "cell-a1b2c3d4e5f6"
+}
+```
+
+`after_cell_id` is optional — omit to append at end.
+
+**Response** `201`
+```json
+{
+ "id": "cell-b2c3d4e5f6a7",
+ "type": "spice",
+ "source": "V1 1 0 DC 5\nR1 1 0 1k\n.op\n.end",
+ "outputs": []
+}
+```
+
+#### Update cell
+
+```
+PUT /api/notebooks/{notebook_id}/cells/{cell_id}
+```
+
+**Request body**
+```json
+{
+ "source": "V1 1 0 DC 10\nR1 1 0 2k\n.op\n.end",
+ "type": "spice"
+}
+```
+
+Both fields optional — only provided fields are updated.
+
+**Response** `200` — Updated `Cell`.
+
+#### Delete cell
+
+```
+DELETE /api/notebooks/{notebook_id}/cells/{cell_id}
+```
+
+**Response** `204`
+
+#### Reorder cells
+
+```
+PUT /api/notebooks/{notebook_id}/cells/reorder
+```
+
+**Request body**
+```json
+{
+ "cell_ids": ["cell-b2c3d4e5f6a7", "cell-a1b2c3d4e5f6"]
+}
+```
+
+Must include every cell ID exactly once.
+
+**Response** `200` — Array of `Cell` objects in new order.
+
+---
+
+### Simulation
+
+#### Run standalone simulation
+
+```
+POST /api/simulate
+```
+
+**Request body**
+```json
+{
+ "netlist": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.op\n.end",
+ "engine": "ngspice"
+}
+```
+
+**Response** `200`
+```json
+{
+ "success": true,
+ "waveform": {
+ "variables": [
+ {"name": "v(1)", "type": "voltage"},
+ {"name": "v(2)", "type": "voltage"}
+ ],
+ "points": 1,
+ "x_data": [0.0],
+ "y_data": {"v(1)": [5.0], "v(2)": [3.333]},
+ "x_type": "time",
+ "is_complex": false,
+ "y_magnitude_db": null,
+ "y_phase_deg": null
+ },
+ "log": "ngspice output...",
+ "error": null,
+ "elapsed_seconds": 0.42
+}
+```
+
+#### Run cell in notebook
+
+```
+POST /api/notebooks/{notebook_id}/cells/{cell_id}/run
+```
+
+No request body — uses the cell's `source` as the netlist and the notebook's `engine`.
+
+**Response** `200` — Same `SimulationResponse` shape. The cell's outputs are updated in the saved notebook.
+
+---
+
+### Schematics
+
+#### Generate schematic from cell
+
+```
+POST /api/notebooks/{notebook_id}/cells/{cell_id}/schematic
+```
+
+No request body. Cell must be type `spice`.
+
+**Response** `200`
+```json
+{
+ "svg": "",
+ "success": true,
+ "error": null,
+ "component_map": {"R1": "resistor", "V1": "voltage_source"}
+}
+```
+
+---
+
+### Waveforms
+
+#### Generate SVG plot (JSON-wrapped)
+
+```
+POST /api/waveforms/svg
+```
+
+**Request body**
+```json
+{
+ "waveform": { "...WaveformData from simulation response..." },
+ "title": "Output Voltage",
+ "width": 800,
+ "height": 500,
+ "signals": ["v(2)"]
+}
+```
+
+`signals` is optional — omit to plot all signals. `width`, `height`, `title` have defaults.
+
+**Response** `200`
+```json
+{
+ "svg": ""
+}
+```
+
+#### Generate SVG plot (raw)
+
+```
+POST /api/waveforms/svg/raw
+```
+
+Same request body as above.
+
+**Response** `200` with `Content-Type: image/svg+xml` — raw SVG string.
+
+---
+
+### Compose (convenience)
+
+Create a fully-populated notebook with multiple cells in a single call.
+
+#### Compose notebook
+
+```
+POST /api/notebooks/compose
+```
+
+**Request body**
+```json
+{
+ "title": "RC Low-Pass Filter",
+ "engine": "ngspice",
+ "tags": ["filter", "rc", "analog"],
+ "cells": [
+ {
+ "type": "markdown",
+ "source": "# RC Low-Pass Filter\n\nA simple first-order low-pass filter."
+ },
+ {
+ "type": "spice",
+ "source": "V1 in 0 AC 1\nR1 in out 1k\nC1 out 0 1u\n.ac dec 100 1 1meg\n.end"
+ },
+ {
+ "type": "markdown",
+ "source": "## Analysis\n\nThe -3dB cutoff is at f = 1/(2*pi*R*C) = 159 Hz."
+ }
+ ],
+ "run": false
+}
+```
+
+| Field | Type | Default | Description |
+|----------|-----------------|----------------------|------------------------------------------------------|
+| title | string | "Untitled Notebook" | Notebook title |
+| engine | string | "ngspice" | Simulation engine |
+| tags | list of strings | [] | Searchable tags |
+| cells | list of objects | (required) | Cells to create, each with `type` and `source` |
+| run | bool | false | If true, execute each SPICE cell after creation |
+
+**Response** `201` — Same shape as `POST /api/notebooks` (notebook with `id` at top level).
+
+When `run` is `true`, each `spice` cell is executed sequentially using the notebook's engine. Simulation results are stored in each cell's `outputs` array. Non-SPICE cells are unaffected.
+
+---
+
+## Data Models
+
+### CellType (enum)
+
+`"markdown"` | `"spice"` | `"python"` | `"schematic"`
+
+### Cell
+
+```json
+{
+ "id": "cell-a1b2c3d4e5f6",
+ "type": "spice",
+ "source": "V1 1 0 DC 5\n.op\n.end",
+ "outputs": [
+ {
+ "output_type": "simulation_result",
+ "data": { "success": true, "waveform": {...}, "log": "...", "error": null, "elapsed_seconds": 0.3 },
+ "timestamp": "2026-02-13T18:35:00+00:00"
+ }
+ ]
+}
+```
+
+### Notebook
+
+```json
+{
+ "spicebook_version": "2026-02-13",
+ "metadata": {
+ "title": "string",
+ "engine": "ngspice",
+ "tags": ["string"],
+ "created": "ISO-8601 datetime",
+ "modified": "ISO-8601 datetime"
+ },
+ "cells": [Cell, ...]
+}
+```
+
+### SimulationResponse
+
+```json
+{
+ "success": true,
+ "waveform": WaveformData | null,
+ "log": "string",
+ "error": "string | null",
+ "elapsed_seconds": 0.0
+}
+```
+
+### WaveformData
+
+```json
+{
+ "variables": [{"name": "v(out)", "type": "voltage"}],
+ "points": 100,
+ "x_data": [0.0, 0.001, ...],
+ "y_data": {"v(out)": [0.0, 0.5, ...]},
+ "x_type": "time",
+ "is_complex": false,
+ "y_magnitude_db": null,
+ "y_phase_deg": null
+}
+```
+
+For AC analysis (`is_complex: true`), `y_magnitude_db` and `y_phase_deg` contain per-signal arrays. `x_type` will be `"frequency"` and `x_data` holds frequency values in Hz.
+
+---
+
+## SPICE Netlist Primer
+
+SpiceBook uses **ngspice** as its simulation engine. Netlists are plain text describing a circuit and the analysis to perform.
+
+### Basic structure
+
+```spice
+* Title line (optional comment)
+V1 node_pos node_neg DC 5 * DC voltage source
+R1 node_a node_b 1k * Resistor: 1 kilo-ohm
+C1 node_a node_b 100n * Capacitor: 100 nanofarads
+L1 node_a node_b 10m * Inductor: 10 millihenrys
+
+.analysis_type parameters
+.end
+```
+
+Node `0` is always ground.
+
+### Supported analysis types
+
+| Command | Description | Example |
+|---------|-------------|---------|
+| `.op` | DC operating point | `.op` |
+| `.dc` | DC sweep | `.dc V1 0 5 0.1` |
+| `.tran` | Transient (time-domain) | `.tran 1u 10m` |
+| `.ac` | AC frequency sweep | `.ac dec 100 1 1meg` |
+
+### Engineering suffixes
+
+| Suffix | Multiplier | Example |
+|--------|-----------|---------|
+| T | 10^12 | `1T` = 1 tera |
+| G | 10^9 | `2.2G` = 2.2 giga |
+| meg | 10^6 | `1meg` = 1 mega (note: not `M` — that's milli in SPICE) |
+| k | 10^3 | `4.7k` = 4700 |
+| m | 10^-3 | `10m` = 0.01 |
+| u | 10^-6 | `100u` = 100 micro |
+| n | 10^-9 | `47n` = 47 nano |
+| p | 10^-12 | `10p` = 10 pico |
+| f | 10^-15 | `1f` = 1 femto |
+
+### Common sources
+
+```spice
+V1 node+ node- DC 5 * DC voltage
+V2 node+ node- AC 1 * AC source (for .ac analysis)
+V3 node+ node- PULSE(0 5 0 1n 1n 5u 10u) * Pulse source
+I1 node+ node- DC 1m * DC current source
+```
+
+---
+
+## Example Workflow
+
+### 1. Create a notebook with the compose endpoint
+
+```bash
+curl -X POST https://spicebook.warehack.ing/api/notebooks/compose \
+ -H "Content-Type: application/json" \
+ -d '{
+ "title": "Voltage Divider",
+ "engine": "ngspice",
+ "tags": ["resistive", "dc", "beginner"],
+ "cells": [
+ {
+ "type": "markdown",
+ "source": "# Voltage Divider\n\nTwo resistors divide a 5V supply."
+ },
+ {
+ "type": "spice",
+ "source": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.op\n.end"
+ },
+ {
+ "type": "markdown",
+ "source": "## Expected Result\n\nV(2) = 5 * 2k / (1k + 2k) = 3.333V"
+ }
+ ],
+ "run": true
+ }'
+```
+
+Response includes the notebook `id` and all cells. Because `run: true`, the SPICE cell's `outputs` array will contain the simulation result with `v(2) = 3.333`.
+
+### 2. View the notebook in the browser
+
+Open `https://spicebook.warehack.ing/notebook/{id}` — the notebook renders with the markdown cells formatted and the SPICE cell showing its simulation output.
+
+### 3. Add another cell
+
+```bash
+curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells \
+ -H "Content-Type: application/json" \
+ -d '{
+ "type": "spice",
+ "source": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.dc V1 0 10 0.5\n.end"
+ }'
+```
+
+### 4. Run the new cell
+
+```bash
+curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells/{cell_id}/run
+```
+
+### 5. Generate a schematic
+
+```bash
+curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells/{cell_id}/schematic
+```
+
+### 6. Visualize waveform data
+
+Take the `waveform` from the simulation response and POST it to the waveform endpoint:
+
+```bash
+curl -X POST https://spicebook.warehack.ing/api/waveforms/svg/raw \
+ -H "Content-Type: application/json" \
+ -d '{
+ "waveform": { ...waveform object from simulation response... },
+ "title": "DC Sweep",
+ "signals": ["v(2)"]
+ }' \
+ -o plot.svg
+```
+
+---
+
+## Health Check
+
+```
+GET /health
+```
+
+**Response** `200`
+```json
+{"status": "ok", "version": "2026.02.13"}
+```