An autonomous multi-agent system that detects pharmaceutical cold-chain failures in real time and reroutes shipments before the cargo is lost.
Vaccines, insulin, and biologics are destroyed if they leave the 2–8 °C cold chain in transit. In India this is genuinely hard: ambient runs 30–40 °C and monsoon flooding turns a 14-hour run into a 30-hour crawl. Today the failure is usually discovered after delivery, when the cargo is already worthless.
Sentinel closes that loop. It simulates a vaccine truck running Mumbai → Bengaluru on NH-48, models the thermal physics of a reefer container fighting Indian heat, and when flooding in the Western Ghats degrades the cooling unit and cargo crosses 8 °C, a five-stage agent pipeline assesses the risk, picks a cold-storage warehouse to divert to, weighs safety against time and cost, and applies the reroute. Every decision is persisted with its full reasoning trace, so the whole chain is auditable months later.
New here — or coming back after a break? Read docs/PROJECT_EXPLAINED.md first. It explains the entire system from zero, assuming no prior knowledge.
Stack: FastAPI · PostgreSQL · Redis Pub/Sub · LangGraph · Groq (Llama 3.3 70B) · React 19 · Vite · Tailwind · Leaflet
| Doc | What it covers |
|---|---|
| docs/PROJECT_EXPLAINED.md | Full walkthrough from zero — every layer, every design decision, interview prep. Start here. |
| apps/backend/README.md | Backend setup and troubleshooting |
| apps/frontend/README.md | Frontend setup |
| docs/API_CONTRACT.md | REST + WebSocket reference |
- System architecture
- The agent pipeline
- How a shipment flows end to end
- Data model
- Design decisions
- Quick start (Docker)
- Local development
- Configuration
- Database migrations
- Testing
- Project layout
- Security notes
Sentinel runs as a single FastAPI process that hosts four cooperating async tasks, coordinated through Redis pub/sub. The simulation produces telemetry, the monitor watches it, the agent graph reacts to it, and the fanout task pushes everything to connected browsers.
graph TB
subgraph Browser["Browser — React 19 SPA"]
UI["Dashboard · Live Map · Shipments · Audit"]
end
subgraph API["FastAPI process"]
REST["REST routers<br/>auth · shipments · dashboard · simulation"]
WS["WebSocket /ws/dashboard<br/>JWT-authenticated"]
subgraph Tasks["Background asyncio tasks"]
SIM["Simulation worker<br/>ticks every 5s"]
MON["Sentinel monitor<br/>threshold watchdog"]
LISTEN["Broadcast listener"]
FAN["Fanout loop<br/>+ 30s heartbeat"]
end
GRAPH["LangGraph agent pipeline"]
end
subgraph Infra["Infrastructure"]
PG[("PostgreSQL<br/>shipments · telemetry<br/>interventions · audit")]
REDIS[("Redis Pub/Sub<br/>4 channels")]
end
GROQ["Groq API<br/>Llama 3.3 70B"]
UI -->|"REST + JWT"| REST
UI <-->|"live events"| WS
REST --> PG
SIM -->|"writes telemetry"| PG
SIM -->|"publishes"| REDIS
REDIS -->|"telemetry_stream"| MON
MON -->|"threshold breached"| GRAPH
GRAPH -->|"structured JSON"| GROQ
GRAPH -->|"decisions + traces"| PG
GRAPH -->|"agent_actions"| REDIS
REDIS --> LISTEN
LISTEN --> FAN
FAN --> WS
style GROQ fill:#f59e0b,color:#000
style PG fill:#336791,color:#fff
style REDIS fill:#dc382d,color:#fff
style GRAPH fill:#7c3aed,color:#fff
Redis is the decoupling boundary. The simulation never calls the agents directly — it publishes telemetry and moves on. This means the agent pipeline can be slow (LLM calls take seconds) without ever stalling the simulation tick.
| Channel | Publisher | Subscriber | Carries |
|---|---|---|---|
telemetry_stream |
Simulation worker | Sentinel monitor, broadcast listener | Position, temperatures, weather, risk score |
agent_actions |
Agent execution node | Broadcast listener | Action taken, reasoning trace, new route |
simulation_lifecycle |
Simulation + agents | Broadcast listener | Departure, zone entry, reroute, delivery |
system_status |
Simulation control routes | Broadcast listener | Simulation started/stopped |
When cargo temperature breaches the threshold, a LangGraph state machine runs five nodes in sequence. Two of them call the LLM; three are deterministic Python. This split is deliberate — see Design decisions.
graph LR
START(["Temperature<br/>breach"]) --> S
S["<b>1. Sentinel</b><br/>Deterministic<br/><i>Gate + audit entry</i>"]
E["<b>2. Environment</b><br/>🤖 LLM<br/><i>Risk assessment</i>"]
D["<b>3. Dispatcher</b><br/>🤖 LLM<br/><i>Warehouse selection</i>"]
SU["<b>4. Supervisor</b><br/>Deterministic<br/><i>Weighted decision</i>"]
X["<b>5. Execution</b><br/>Deterministic<br/><i>Apply + persist</i>"]
S --> E --> D --> SU --> X --> END(["Status updated<br/>+ broadcast"])
style S fill:#3b82f6,color:#fff
style E fill:#f59e0b,color:#000
style D fill:#f59e0b,color:#000
style SU fill:#3b82f6,color:#fff
style X fill:#3b82f6,color:#fff
| Node | Type | Responsibility | Output |
|---|---|---|---|
| Sentinel | Deterministic | Confirms the threshold gate was crossed; writes the opening audit record | gate_passed |
| Environment | LLM | Reads telemetry (temps, weather, severity, position) and assesses cold-chain risk | risk_level, risk_score, recommended_action, reasoning |
| Dispatcher | LLM | Given the risk assessment + real warehouse candidates from the DB, picks a diversion target | warehouse_candidate_id, safety_score, time_score, cost_score, ETA |
| Supervisor | Deterministic | Applies configurable weights to the dispatcher's scores and decides what actually happens | reroute_route | continue_route | emergency_staging |
| Execution | Deterministic | Mutates shipment status, writes route history, publishes to Redis | Applied action + broadcast |
The Supervisor is intentionally not an LLM — it is the authority that decides whether cargo actually gets diverted, so its logic must be inspectable and reproducible.
flowchart TD
IN(["Environment risk +<br/>Dispatcher scores"]) --> Q1{"risk ≥ 0.88<br/>or 'emergency'<br/>recommended?"}
Q1 -->|Yes| EM["<b>emergency_staging</b><br/>Mark compromised"]
Q1 -->|No| Q2{"Dispatcher<br/>returned a<br/>warehouse?"}
Q2 -->|No| CONT["<b>continue_route</b><br/>Keep monitoring"]
Q2 -->|Yes| CALC["weighted_score =<br/>0.5·safety + 0.3·time + 0.2·cost"]
CALC --> Q3{"risk ≥ 0.35 AND safety ≥ 0.35<br/>AND action mentions reroute?"}
Q3 -->|Yes| RR["<b>reroute_route</b><br/>Divert to warehouse"]
Q3 -->|No| Q4{"risk ≥ 0.5<br/>AND safety ≥ 0.45?"}
Q4 -->|Yes| RR
Q4 -->|No| CONT
style EM fill:#dc2626,color:#fff
style RR fill:#16a34a,color:#fff
style CONT fill:#64748b,color:#fff
Weights are set via SUPERVISOR_WEIGHT_SAFETY / _TIME / _COST and are validated at startup to sum to exactly 1.0 — the app refuses to boot otherwise.
This is the full "hero" scenario the demo runs: a reefer truck leaves a Mumbai cold room bound for Bengaluru on NH-48, hits monsoon flooding in the Western Ghats, its cargo temperature climbs past the 8 °C vaccine limit, and the agents divert it to a cold-storage warehouse.
sequenceDiagram
autonumber
participant U as Browser
participant API as FastAPI
participant SIM as Simulation worker
participant R as Redis
participant MON as Sentinel monitor
participant AG as Agent graph
participant G as Groq LLM
participant DB as PostgreSQL
U->>API: POST /simulation/start/{id}
API->>DB: reset position, status = in_transit
API->>R: publish system_status
loop Every SIMULATION_TICK_SECONDS (default 5s)
SIM->>SIM: advance position along polyline
SIM->>SIM: step thermal model + weather
SIM->>DB: INSERT telemetry_log
SIM->>R: publish telemetry_stream
R-->>U: live map + temperature update
end
Note over SIM: Truck enters Ghats flood zone —<br/>reefer degrades, cargo temp climbs
R->>MON: telemetry (internal_temp ≥ 8 °C)
MON->>MON: dedupe — fire once per breach
MON->>AG: trigger pipeline
AG->>DB: audit: sentinel gate_passed
AG->>G: Environment — assess risk
G-->>AG: {risk_level, risk_score, recommended_action}
AG->>DB: audit: environment assessment
AG->>DB: SELECT warehouse_candidates
AG->>G: Dispatcher — pick warehouse
G-->>AG: {warehouse_id, safety/time/cost scores}
AG->>AG: validate warehouse_id against DB set
AG->>DB: audit: dispatcher selection
AG->>AG: Supervisor — weighted decision
AG->>DB: audit: supervisor decision
AG->>DB: UPDATE shipment status = rerouted
AG->>DB: INSERT route_history
AG->>R: publish agent_actions
R-->>U: reroute animates on map + reasoning shown
Note over SIM: Next tick switches to alternate<br/>geometry; cargo temp recovers
SIM->>DB: UPDATE status = delivered
SIM->>R: publish lifecycle: shipment_delivered
R-->>U: shipment closed within band
The simulation is not random noise — it is a small deterministic model, which is what makes the demo reproducible.
Thermal model (app/simulation/telemetry_gen.py) — first-order relaxation toward ambient plus the reefer unit pulling toward its setpoint. All temperatures in °C:
ΔT_cargo = dt · [ k_ambient · (T_ambient − T_cargo) + k_reefer · efficiency · (T_setpoint − T_cargo) ]
The insulated box couples weakly to ambient (k_ambient = 0.08) while the reefer pulls hard (k_reefer = 1.2). A healthy unit therefore settles around 6.8 °C against 34 °C outside — safely inside the band. Flooding degrades the unit's authority:
efficiency = max(0.35, 1 − 0.65 · weather_severity)
At full flooding the reefer keeps only 35% of its cooling power, ambient wins, and cargo drifts past 8 °C. That is the entire premise of the demo, expressed in two constants — and it is covered by unit tests so it cannot silently regress.
Risk score (app/simulation/disruption_zones.py) — deterministic, in [0, 1]:
risk = 0.45 · thermal_band_violation + 0.35 · weather_severity + 0.20 · in_monsoon_zone
Cold-chain safety rules enforced by the simulation worker:
| Condition | Result |
|---|---|
| Cargo above 10 °C (band high + 2) for 3 consecutive ticks | compromised — sustained excursion, cargo written off |
| Cargo back at or below 8 °C for 3 ticks after a reroute | Temperature recovered, reroute window unlocked |
| Truck position inside the Western Ghats bounding box | Weather severity +0.38, ambient +2 °C per severity unit |
| Arrives at destination above 8 °C | compromised — arriving out of spec is not a delivery |
| Arrives within band | delivered |
erDiagram
USERS {
int id PK
string username UK
string email UK
string hashed_password
enum role "admin | viewer"
bool is_active
}
SHIPMENTS {
int id PK
string shipment_code UK
string cargo_type
string origin
string destination
enum status "in_transit | rerouted | compromised | delivered"
float current_lat
float current_lng
float target_temp_low
float target_temp_high
}
TELEMETRY_LOGS {
int id PK
int shipment_id FK
datetime timestamp
float lat
float lng
float internal_temp
float external_temp
string weather_state
float risk_score
jsonb raw_payload_json
}
INTERVENTION_LOGS {
int id PK
int shipment_id FK
datetime timestamp
string agent_role
text trigger_reason
text reasoning_trace
string action_taken
float confidence_score
jsonb raw_model_output_json
}
ROUTE_HISTORY {
int id PK
int shipment_id FK
string route_name
text reason
float distance_km
float eta_minutes
}
LIFECYCLE_EVENT_LOGS {
int id PK
int shipment_id FK
string event
jsonb payload_json
}
WAREHOUSE_CANDIDATES {
int id PK
string name
float lat
float lng
string state
bool has_cold_storage
int capacity_units
}
SHIPMENTS ||--o{ TELEMETRY_LOGS : "emits"
SHIPMENTS ||--o{ INTERVENTION_LOGS : "triggers"
SHIPMENTS ||--o{ ROUTE_HISTORY : "records"
SHIPMENTS ||--o{ LIFECYCLE_EVENT_LOGS : "logs"
intervention_logs is the audit spine. Every agent — including the deterministic ones — writes a row with its full reasoning trace and raw model output. That is what makes the /shipments/{slug}/audit page possible: you can reconstruct exactly why a shipment was diverted, months later.
These are the choices worth knowing about, and why they were made.
Redis sits between simulation and agents. The simulation ticks every 5 seconds; an LLM round trip takes several seconds. If the monitor called the agent graph inline, one slow Groq response would stall every shipment's physics. Publishing to telemetry_stream and reacting asynchronously means the pipeline can take as long as it needs. It also means the agent pipeline could be extracted into its own service without changing the simulation at all.
Only two of the five agents are LLMs. The LLM is used where judgment genuinely helps — interpreting a messy environmental situation, and ranking warehouse options. The decision that actually commits cargo to a new route is plain weighted arithmetic in the Supervisor. A regulator asking "why was this shipment diverted?" gets a deterministic, reproducible answer, not a sampled token stream.
LLM output is validated twice. Groq is asked for response_format: json_object, the response is parsed into a Pydantic model (app/agents/llm_schemas.py), and then the Dispatcher's chosen warehouse_candidate_id is checked against the actual set of warehouse IDs from the database. A hallucinated warehouse ID short-circuits to continue_route rather than corrupting the shipment.
The system degrades instead of crashing. If Redis is unavailable, the API still starts — /health reports redis: false, the simulation and monitor simply don't launch, and REST endpoints keep working. If Postgres is unreachable, startup fails fast with an actionable message telling you to run docker compose up -d postgres redis, rather than a raw connection traceback.
Sentinel fires once per breach, not once per tick. Without deduplication, a shipment sitting below the threshold for ten ticks would trigger ten concurrent LLM pipelines. app_state.sentinel_fired_this_breach gates this, and clears when the temperature recovers above the threshold. A per-shipment asyncio.Lock guarantees only one pipeline per shipment runs at a time.
Auth is JWT bearer, and the WebSocket enforces it too. REST routes use an Authorization: Bearer header. Browsers cannot set custom headers on a WebSocket constructor, so /ws/dashboard takes the same JWT as a token query parameter and validates it against the database before accepting the connection — an unauthenticated socket is closed with code 1008.
The fastest path to a running system.
git clone https://github.com/K-Ananthamoorthy/sentinal.git
cd sentinal
cp .env.example .envEdit .env and set your GROQ_API_KEY (get one free at console.groq.com/keys) and a long random SECRET_KEY. Then:
docker compose up --build| Service | URL |
|---|---|
| Frontend | http://localhost:5173 |
| API | http://localhost:8000 |
| Swagger docs | http://localhost:8000/docs |
| Health check | http://localhost:8000/health |
Log in with the seeded admin account — demo / SentinelDemo2026! — then open Simulation, pick the demo shipment, and press Start.
Infrastructure only, no API key needed:
docker compose up -d postgres redis
This project uses uv for Python and Bun for the frontend.
# Install both if you don't have them
curl -LsSf https://astral.sh/uv/install.sh | sh # macOS/Linux
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
powershell -c "irm bun.sh/install.ps1 | iex"docker compose up -d postgres rediscd apps/backend
uv venv
uv pip install -r requirements.txt
cp .env.example .env # then set GROQ_API_KEY and SECRET_KEYRun it:
uv run uvicorn app.main:app --reload --port 8000On first start with empty tables, the API seeds the demo admin user, warehouse candidates, and the demo shipment SNT-DEMO-001.
cd apps/frontend
bun install
cp .env.example .env.local
bun run devOpens on http://localhost:5173. In dev, Vite proxies /auth, /shipments, /dashboard, /health, and /ws to the backend, so you can leave VITE_API_BASE_URL empty.
From the repo root you can also use:
bun run dev:frontendSecrets live in .env files, which are gitignored. Only .env.example templates are tracked.
GROQ_API_KEY belongs in apps/backend/.env only — never in the frontend. Anything prefixed VITE_ is compiled into the browser bundle and is public. If a key is ever committed or pasted somewhere public, rotate it at console.groq.com/keys.
| Variable | Default | What it does |
|---|---|---|
SECRET_KEY |
— | JWT signing key, min 16 chars. Required. |
DATABASE_URL |
— | Async SQLAlchemy URL (postgresql+asyncpg://…). Required. |
REDIS_URL |
— | redis://host:port/db. Required. |
GROQ_API_KEY |
— | Groq API key. Required. |
GROQ_MODEL |
llama-3.3-70b-versatile |
Model used by both LLM agents |
SIMULATION_TICK_SECONDS |
5 |
Real seconds between simulation ticks |
SIMULATED_MINUTES_PER_TICK |
15 |
Simulated minutes advanced per tick |
TEMPERATURE_THRESHOLD_C |
8.0 |
Cargo temp at or above which the agent pipeline fires |
TARGET_TEMP_LOW_C / _HIGH_C |
2.0 / 8.0 |
Safe cargo band (WHO vaccine cold chain) |
THERMAL_COUPLING_PER_HOUR |
0.08 |
How fast cargo drifts toward ambient (low — insulated box) |
REEFER_SETPOINT_C |
5.0 |
Refrigeration unit target |
REEFER_STRENGTH_PER_HOUR |
1.2 |
How hard the reefer pulls toward setpoint |
SUPERVISOR_WEIGHT_SAFETY |
0.5 |
Supervisor weights — must sum to 1.0 |
SUPERVISOR_WEIGHT_TIME |
0.3 |
|
SUPERVISOR_WEIGHT_COST |
0.2 |
|
CORS_ORIGINS |
http://localhost:5173 |
Comma-separated allowed origins |
| Variable | Purpose |
|---|---|
VITE_API_BASE_URL |
REST base, e.g. http://localhost:8000. Leave empty in dev to use the Vite proxy. |
VITE_WS_URL |
WebSocket URL, e.g. ws://localhost:8000/ws/dashboard |
Schema is managed by Alembic. The app runs alembic upgrade head automatically on startup, so a fresh clone needs no manual migration step — but the commands are there when you change a model.
cd apps/backend
uv run alembic upgrade head # apply pending migrations
uv run alembic revision --autogenerate -m "add column" # after editing models.py
uv run alembic downgrade -1 # roll back one revision
uv run alembic current # what's applied nowmigrations/env.py reads DATABASE_URL from settings, so alembic.ini never holds credentials.
Upgrading from a pre-Alembic database? Older volumes were built with
create_alland have noalembic_versiontable, so a schema that predates theorigin/destinationcolumns will fail on boot. Reset withdocker compose down -v(this deletes the volume) and start again.
cd apps/backend
uv run pytestTests are SQLite-backed and require no running infrastructure — tests/conftest.py sets the necessary environment variables before the app imports. Coverage focuses on the deterministic core: thermal stepping, risk scoring, route geometry, LLM schema validation, and JWT/password security.
Frontend typecheck and build:
cd apps/frontend
bun run lint # tsc --noEmit
bun run buildCI runs all of the above plus both Docker image builds on every push and pull request — see .github/workflows/ci.yml.
sentinal/
├── apps/
│ ├── backend/
│ │ ├── app/
│ │ │ ├── agents/ # LangGraph pipeline, prompts, LLM output schemas
│ │ │ ├── api/ # REST routers + WebSocket endpoint
│ │ │ ├── core/ # Settings, JWT/password, logging, shared app state
│ │ │ ├── database/ # ORM models, CRUD, session, seed data
│ │ │ ├── schemas/ # Pydantic request/response models
│ │ │ ├── services/ # Groq client, Redis pub/sub, background workers
│ │ │ ├── simulation/ # Thermal model, route geometry, weather, risk zones
│ │ │ └── main.py # App factory, lifespan, task orchestration
│ │ ├── migrations/ # Alembic revisions
│ │ └── tests/
│ └── frontend/
│ └── src/
│ ├── components/ # Layout shell + shadcn-style UI primitives
│ ├── lib/ # API client, WebSocket URL builder
│ ├── pages/ # Home, Dashboard, Live Map, Shipments, Detail, Audit
│ └── store/ # Zustand auth store (persisted)
├── docs/ # API contract, specs, project proposal
├── .github/workflows/ci.yml
└── docker-compose.yml
| Method | Route | Auth |
|---|---|---|
POST |
/auth/register · /auth/login |
Public |
GET |
/auth/me |
Bearer |
GET |
/shipments · /shipments/{id} · /shipments/by-code/{code} |
Bearer |
GET |
/shipments/{id}/telemetry · /interventions · /route-overlay |
Bearer |
POST |
/shipments · /shipments/auto |
Admin |
PATCH DELETE |
/shipments/{id} |
Admin |
GET |
/dashboard/summary · /live-state · /events · /scenario |
Bearer |
POST |
/simulation/start/{id} · /simulation/stop/{id} |
Bearer |
GET |
/simulation/events/{id} · /simulation/lifecycle/{id} |
Bearer |
WS |
/ws/dashboard?token=…&shipment_id=… |
Token query param |
GET |
/health |
Public |
Full details in docs/API_CONTRACT.md and the live Swagger UI at /docs.
- Passwords are hashed with bcrypt via passlib; plaintext is never stored or logged.
- JWTs are HS256, signed with
SECRET_KEY, expiring afterACCESS_TOKEN_EXPIRE_MINUTES(default 24h). - Role-based access:
adminis required to create, modify, or delete shipments;vieweris read-only. New registrations default toviewer. - The WebSocket validates its JWT against the database and closes unauthenticated connections with code 1008.
- CORS is restricted to the origins in
CORS_ORIGINS— not a wildcard. - Schema changes go through Alembic migrations, not
create_all— see Database migrations.
Worth being upfront about, since this started as a prototype:
- Simulation state is in-process memory.
SimulationEngine.stateslives in the FastAPI process, so the backend does not horizontally scale as-is. Moving that state into Redis would be the first step toward multi-replica deployment. - The frontend bundle is ~500 KB (Leaflet + React Query). Route-level code splitting would bring this down.
- No end-to-end tests. Backend unit tests cover the deterministic core; the agent pipeline and the UI are verified manually.