# Developer quickstart

> Bring the full chain up locally from source — witness, query plane, gateway — and ask Claude Desktop a question against real witness data.

*Canonical HTML: https://www.conversationalfactory.com/docs/dev-quickstart*
*Markdown source: https://www.conversationalfactory.com/docs/dev-quickstart.md*

---

This is the developer runbook for **Tier 1 demo ship** — bringing the full chain up locally and asking Claude Desktop a question against real witness data. For the operator-grade walkthrough (Docker only, no Rust toolchain), see the [operator quickstart](/docs/quickstart).

## Prerequisites

- macOS or Linux dev host
- Docker + Docker Compose (for the witness)
- Rust toolchain (`rustup default stable`, MSRV 1.85+)
- `mkcert` (`brew install mkcert` on macOS) — only needed if you're testing mTLS
- Optional: Claude Desktop or another MCP-aware client

## The chain

```
   You / Claude Desktop
            │
            │ stdio MCP (or HTTP /mcp)
            ▼
    Conversational Gateway   (off-box; this terminal)
            │
            │ i3X v1 over HTTP or HTTPS+mTLS
            ▼
        Query Plane          (on-box; Rust)
            │
            │ HTTP to /i3x/v0/*
            ▼
       Eris Witness          (on-box; Python in Docker)
            │
            │ tshark capture + Postgres + Redis
            ▼
      Network / PCAP fixtures
```

## Step 1 — Bring up the witness

The Python witness lives at `~/eriswitness/` and is symlinked at `services/witness/`. It's its own multi-service Docker Compose stack (Flask app + 15 workers + Postgres + Redis).

> **macOS without Docker Desktop?** Colima provides the Docker socket. Install with `brew install colima docker docker-compose`, start with `colima start`, then export `DOCKER_HOST=unix://$HOME/.colima/default/docker.sock` so `docker compose` finds it.

### 1a. Populate `.env`

The witness reads required secrets from `services/witness/.env`. Five are NOT NULL and must be set before the first boot:

```sh
cd services/witness
[ -f .env ] || cp .env.example .env

cat >>.env <<'EOF'
DB_PASSWORD=devpassword
SECRET_KEY=dev-secret-not-for-prod
ADMIN_PASSWORD=admin-dev-password
ARTIFACT_ENCRYPTION_KEY=dev-artifact-key-32-bytes-long!
SMTP_FROM=witness@localhost
EOF
```

For a real deployment, generate strong values and store them via your secrets manager — these defaults are dev-only.

### 1b. Build and run the stack

```sh
docker compose build              # ~10 min on first run (Cython compile)
docker compose up -d
```

### 1c. Run database migrations

The compose stack does **not** run migrations on first boot. The app will crashloop with `FATAL: required tables missing` until you do this once:

```sh
docker compose run --rm --entrypoint "alembic upgrade head" app
docker compose restart app
```

Confirm the stack is healthy:

```sh
docker compose ps
curl -k http://127.0.0.1:5001/
```

### 1d. Provision an org and i3X API key (headless)

```sh
docker exec eriswitness-dev-app python -c "
from app import create_app, db
from app.models import Organization
import secrets, hashlib

app = create_app()
with app.app_context():
    org = Organization.query.filter_by(name='default').first() or Organization(name='default')
    raw_key = 'cf-' + secrets.token_hex(16)
    org.i3x_api_key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
    db.session.add(org); db.session.commit()
    print(f'ORG_ID={org.id}')
    print(f'I3X_API_KEY={raw_key}')
"
```

The `I3X_API_KEY` line is your `WITNESS_API_KEY` for Step 2 — copy it now, the hash is one-way.

### 1e. Load sample data

**Option A — load a PCAP fixture (recommended for realistic data):** in the web UI go to **New Scan → Upload PCAP** and select a file from `services/witness/presets/ics-ot/`.

**Option B — seed a few assets directly (for smoke testing):**

```sh
docker exec eriswitness-dev-app python -c "
from app import create_app, db
from app.models import Asset
from datetime import datetime, timezone

app = create_app()
with app.app_context():
    now = datetime.now(timezone.utc)
    for ip, mac, vendor, role in [
        ('10.5.0.10', '00:1c:06:aa:bb:01', 'Allen-Bradley', 'PLC'),
        ('10.5.0.11', '00:0e:8c:cc:dd:02', 'Siemens',       'HMI'),
        ('10.5.0.12', '00:80:f4:ee:ff:03', 'Schneider',     'Sensor'),
    ]:
        a = Asset(ip=ip, mac=mac, vendor=vendor, role=role,
                  first_seen=now, last_seen=now, organization_id=1)
        db.session.add(a)
    db.session.commit()
    print('seeded 3 assets')
"
```

## Step 2 — Bring up the query plane

In a new terminal at the conversational-factory repo root:

```sh
WITNESS_URL=http://127.0.0.1:5001 \
  WITNESS_API_KEY="<paste-the-key-from-step-1>" \
  cargo run -p query-plane
```

Verify connectivity:

```sh
curl -s http://127.0.0.1:8090/v1/info | jq
# expect capabilities.query.history = true (witness wired)
```

## Step 3 — Bring up the gateway

```sh
QUERY_PLANE_URL=http://127.0.0.1:8090 \
  AUDIT_LOG_PATH=/tmp/cf-audit.jsonl \
  cargo run -p conversational-gateway
```

Smoke check:

```sh
curl -s http://127.0.0.1:8091/health
curl -s http://127.0.0.1:8091/tools | jq '.tools | length'
```

## Step 4 — Make a real query (HTTP path)

```sh
curl -s http://127.0.0.1:8090/v1/objects | jq '.result[].elementId' | head -10

ELEMENT_ID="<paste-an-elementId-from-above>"

curl -s -X POST http://127.0.0.1:8091/query \
  -H 'content-type: application/json' \
  -d "$(jq -nc --arg eid "$ELEMENT_ID" '{
    request_id: "00000000-0000-0000-0000-000000000001",
    intent: "get-current-state",
    elementId: $eid,
    maxDepth: 2
  }')" | jq
```

Inspect the audit chain:

```sh
tail -1 /tmp/cf-audit.jsonl | jq
```

## Step 5 — Connect Claude Desktop (optional, MCP path)

Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "factory": {
      "command": "/path/to/conversational-factory/target/release/conversational-gateway",
      "args": ["--stdio"],
      "env": {
        "QUERY_PLANE_URL": "http://127.0.0.1:8090",
        "AUDIT_LOG_PATH": "/tmp/cf-audit.jsonl"
      }
    }
  }
}
```

Build the gateway in release mode:

```sh
cargo build --release -p conversational-gateway
```

Restart Claude Desktop. Ask: *"Use the factory MCP server. What objects exist on Cell 5? Get the current state of one of them."*

## Step 6 — Verify the audit chain

```sh
cat /tmp/cf-audit.jsonl | jq -s 'group_by(.tool) | map({tool: .[0].tool, count: length})'
```

The audit log carries: the natural-language question, the exact tool dispatched, the parameters, the downstream i3X call(s), and the status returned to the AI.

## Dev mode vs production

| Concern | Dev (this guide) | Production |
|---|---|---|
| Query plane transport | Plain HTTP loopback | mTLS with operator-issued certs |
| Gateway location | Same host as query plane | Off-box on operator workstation / broker box |
| Witness API key | Read from logs / web UI | Provisioned via signed-config artifact |
| Audit log path | `/tmp/cf-audit.jsonl` | `/var/log/conversational-factory/gateway-audit.jsonl` with rotation |
| Sample data | Loaded from `presets/` | Real plant traffic |

## Troubleshooting

If something doesn't behave, run `make smoke-run`. Common surprises:

- **`docker compose up` exits with `app` crashlooping** — schema not migrated. Run the `alembic upgrade head` step from 1c.
- **`docker compose up` complains about missing required env** — five secrets in 1a (`DB_PASSWORD`, `SECRET_KEY`, `ADMIN_PASSWORD`, `ARTIFACT_ENCRYPTION_KEY`, `SMTP_FROM`) are NOT NULL.
- **Query plane reports `query.history: false`** — witness URL or API key is wrong; ping failed at startup.
- **`get_current_state` returns `GoodNoData` for everything** — witness has no AssetDB rows yet; load a PCAP or run the seed snippet from 1e.
- **`Asset insert IntegrityError: first_seen ... not-null`** — both `first_seen` and `last_seen` are required.
- **`tools/call` returns `audit-failure`** — `AUDIT_LOG_PATH` directory isn't writable by the gateway process.
- **Gateway can't reach query plane** — port mismatch (default 8090) or one process is bound to localhost only.
- **Witness rejects the i3X bearer token** — key was regenerated but env var still has the old hash.
