LLM-driven system boundary & responsibility mapper on Civo
Transform unstructured architecture notes into interactive system diagrams showing team ownership using LLMs on Civo.
Written by
Software Engineer @ GoCardless
Written by
Software Engineer @ GoCardless
Your architecture knowledge is scattered across Slack threads, wiki pages, and incident postmortems. Teams lose track of system boundaries, component ownership, and who's responsible for what. Manually maintaining system maps? They're outdated the moment you finish them.
This tutorial fixes that. You'll build a tool that automatically generates interactive system maps from messy architecture notes. Paste unstructured text, get color-coded diagrams showing team ownership and component relationships. Everything runs on Civo Kubernetes with relaxAI handling the LLM inference.
This tutorial is aimed at intermediate-level developers comfortable with Python and Docker. Kubernetes experience is helpful but not required.
Prerequisites
Before starting, ensure you have:
- Civo Account with GPU access
- Docker installed locally
- Python installed
- relaxAI API Key from the relaxAI dashboard (navigate to API Keys section)
- Kubectl installed
Project Structure
Create the following directory structure:
system-mapper/├── backend/│ ├── Dockerfile│ ├── requirements.txt│ └── app.py├── frontend/│ ├── Dockerfile│ ├── index.html│ └── app.js└── kubernetes/├── backend-deployment.yaml└── frontend-deployment.yaml
Step 1: Create and connect to your Civo Kubernetes cluster
- Log in to the Civo Dashboard
- Create a new Kubernetes cluster
- Add a GPU node pool (one node is sufficient for this demo)
- Download your
kubeconfigfile - Connect to the cluster by running
set KUBECONFIG=path-to-kubeconfig-filein your terminal (useexportinstead ofseton Linux or macOS) - Verify connectivity by running
kubectl get nodes
While this demo processes lightweight inputs, the GPU-enabled cluster ensures the setup can handle larger, more compute-intensive inference workloads without redesigning the system.
Step 2: Build the backend
In this step, you’ll build the FastAPI backend that powers the system mapper. This service accepts raw architecture notes, sends them to relaxAI for structured extraction, and returns a clean JSON response containing nodes and edges.
By the end of this step, you’ll have a working /map endpoint running locally that converts unstructured text into a structured system graph. Open app.py. In this file, you’ll add all the code in this step, building the backend incrementally from imports to the /map endpoint and the local run block.
Import necessary libraries:
from fastapi import FastAPI, HTTPExceptionfrom fastapi.middleware.cors import CORSMiddlewarefrom pydantic import BaseModelfrom openai import OpenAIimport osimport json
Initialize the FastAPI application:
app = FastAPI()app.add_middleware(CORSMiddleware,allow_origins=["*"], # For demo purposes; tighten for productionallow_credentials=True,allow_methods=["*"],allow_headers=["*"],)
Configure the relaxAI client:
client = OpenAI(api_key=os.environ.get("RELAXAI_API_KEY"),base_url="https://api.relax.ai/v1")
Define request and response models:
class ArchitectureNotes(BaseModel):"""Input: raw, unstructured architecture notes"""notes: strclass SystemMap(BaseModel):"""Output: structured system map with nodes and edges"""nodes: listedges: list
Define the extraction prompt:
EXTRACTION_PROMPT = """You are a system architecture extraction engine.STRICT REQUIREMENTS:- OUTPUT ONLY JSON.- NO explanation.- NO natural language.- NO markdown.- NO code fences (no ```json or ```).- NO commentary.- If unsure about a component or relationship, omit it.Extract system components (nodes) and their relationships (edges) from the architecture notes.REQUIRED JSON FORMAT (follow EXACTLY):{"nodes": [{"id": "unique-component-id","label": "Component Name","team": "Team Name","type": "service","description": "Brief description"}],"edges": [{"source": "component-id-1","target": "component-id-2","relationship": "calls"}]}VALID node types: service, database, frontend, queue, boundaryVALID relationship types: calls, stores_in, publishes_to, reads_fromIDs: lowercase-with-hyphens (e.g., "user-service", "postgres-db")Descriptions: under 100 charactersArchitecture notes:"""
Add a health check endpoint:
@app.get("/health")def health_check():"""Simple health check endpoint for Kubernetes liveness probes"""return {"status": "healthy"}
Implement the /map endpoint:
@app.post("/map", response_model=SystemMap)async def generate_map(input_data: ArchitectureNotes):# Validate inputif not input_data.notes or len(input_data.notes.strip()) < 10:raise HTTPException(status_code=400,detail="Notes must contain at least 10 characters")try:# Call relaxAI with extraction promptresponse = client.chat.completions.create(model="Llama-4-Maverick-17B-128E", # Fast, capable modelmessages=[{"role": "system","content": "You are a precise system architecture analyzer. Return only valid JSON."},{"role": "user","content": EXTRACTION_PROMPT + input_data.notes}],temperature=0.3, # Lower temperature for more consistent structured outputmax_tokens=2000)# Extract LLM responsellm_output = response.choices[0].message.content.strip()# Parse JSON (LLM sometimes wraps in markdown code blocks)if llm_output.startswith("```json"):llm_output = llm_output.split("```json")[1].split("```")[0].strip()elif llm_output.startswith("```"):llm_output = llm_output.split("```")[1].split("```")[0].strip()system_map = json.loads(llm_output)# Basic validationif "nodes" not in system_map or "edges" not in system_map:raise ValueError("LLM output missing required fields: nodes, edges")return SystemMap(**system_map)except json.JSONDecodeError as e:raise HTTPException(status_code=500,detail=f"LLM returned invalid JSON: {str(e)}")except Exception as e:raise HTTPException(status_code=500,detail=f"Error generating map: {str(e)}")
Add the local development entry point:
if __name__ == "__main__":import uvicornuvicorn.run(app, host="0.0.0.0", port=8000)
At this point, your backend is complete and ready to accept architecture notes and return structured system data. In the next step, you’ll build a minimal frontend to visualize that data as an interactive system map.
Step 3: Build the frontend
Now that your backend can extract structured system maps from unstructured notes, you’ll build a minimal frontend to:
- Accept architecture notes
- Send them to the
/mapendpoint - Render the structured output as an interactive diagram
Create the frontend files
Create the following two files inside the frontend/ directory of your project:
The frontend consists of:
index.html→ Full UI layoutapp.js→ Logic
Index.html
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>System Boundary Mapper</title><!-- Cytoscape.js: JavaScript library for visualizing and interacting with graphs --><script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script><style>* {margin: 0;padding: 0;box-sizing: border-box;}body {font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;background: #f5f5f5;padding: 20px;}.container {max-width: 1400px;margin: 0 auto;background: white;border-radius: 8px;box-shadow: 0 2px 8px rgba(0,0,0,0.1);overflow: hidden;}.header {background: #2563eb;color: white;padding: 30px;text-align: center;}.header h1 {margin-bottom: 10px;font-size: 28px;}.header p {opacity: 0.9;font-size: 16px;}.content {display: grid;grid-template-columns: 400px 1fr;min-height: 600px;}.input-panel {padding: 30px;border-right: 1px solid #e5e7eb;background: #fafafa;}.input-panel h2 {margin-bottom: 15px;font-size: 18px;color: #1f2937;}.input-panel textarea {width: 100%;height: 300px;padding: 15px;border: 1px solid #d1d5db;border-radius: 6px;font-family: monospace;font-size: 14px;resize: vertical;margin-bottom: 15px;}.input-panel textarea:focus {outline: none;border-color: #2563eb;}.input-panel button {width: 100%;padding: 12px;background: #2563eb;color: white;border: none;border-radius: 6px;font-size: 16px;font-weight: 600;cursor: pointer;transition: background 0.2s;}.input-panel button:hover {background: #1d4ed8;}.input-panel button:disabled {background: #9ca3af;cursor: not-allowed;}.status {margin-top: 15px;padding: 10px;border-radius: 6px;font-size: 14px;text-align: center;}.status.loading {background: #dbeafe;color: #1e40af;}.status.success {background: #d1fae5;color: #065f46;}.status.error {background: #fee2e2;color: #991b1b;}.graph-panel {position: relative;}#graph {width: 100%;height: 600px;background: #ffffff;}.node-details {position: absolute;top: 20px;right: 20px;background: white;padding: 20px;border-radius: 6px;box-shadow: 0 4px 12px rgba(0,0,0,0.15);max-width: 300px;display: none;}.node-details.active {display: block;}.node-details h3 {margin-bottom: 10px;color: #1f2937;font-size: 18px;}.node-details .detail-row {margin-bottom: 8px;font-size: 14px;}.node-details .detail-label {font-weight: 600;color: #6b7280;margin-right: 5px;}.legend {position: absolute;bottom: 20px;left: 20px;background: white;padding: 15px;border-radius: 6px;box-shadow: 0 2px 8px rgba(0,0,0,0.1);}.legend h4 {margin-bottom: 10px;font-size: 14px;color: #1f2937;}.legend-item {display: flex;align-items: center;margin-bottom: 6px;font-size: 13px;}.legend-color {width: 20px;height: 20px;border-radius: 4px;margin-right: 8px;}.example-link {margin-top: 15px;padding: 10px;background: #eff6ff;border-radius: 6px;font-size: 13px;color: #1e40af;cursor: pointer;text-align: center;}.example-link:hover {background: #dbeafe;}</style></head><body><div class="container"><div class="header"><h1>System Boundary & Responsibility Mapper</h1><p>Turn messy architecture notes into interactive system diagrams</p></div><div class="content"><div class="input-panel"><h2>Architecture Notes</h2><textareaid="notes"placeholder="Paste your architecture notes here..."></textarea><button id="generateBtn">Generate Map</button><div id="status" class="status" style="display: none;"></div></div><div class="graph-panel"><div id="graph"></div><div id="nodeDetails" class="node-details"><h3 id="nodeName"></h3><div class="detail-row"><span class="detail-label">Team:</span><span id="nodeTeam"></span></div><div class="detail-row"><span class="detail-label">Type:</span><span id="nodeType"></span></div><div class="detail-row"><span class="detail-label">Description:</span><span id="nodeDescription"></span></div></div><div class="legend"><h4>Team Ownership</h4><div class="legend-item"><div class="legend-color" style="background: #ef4444;"></div>Commerce</div><div class="legend-item"><div class="legend-color" style="background: #10b981;"></div>Catalog</div><div class="legend-item"><div class="legend-color" style="background: #3b82f6;"></div>Identity</div><div class="legend-item"><div class="legend-color" style="background: #8b5cf6;"></div>Platform</div><div class="legend-item"><div class="legend-color" style="background: #06b6d4;"></div>Data</div><div class="legend-item"><div class="legend-color" style="background: #f59e0b;"></div>UI</div><div class="legend-item"><div class="legend-color" style="background: #f97316;"></div>Operations</div></div></div></div></div><script src="app.js"></script></body></html>
Here’s how the frontend should look:
app.js
Configure backend URL:
const API_URL = window.location.hostname === 'localhost'? 'http://localhost:8000': 'http://<BACKEND-EXTERNAL-IP>:8000';
Important note: The above value is effectively baked into the Docker image when the frontend is built. This means you must:
- Deploy the backend first
- Wait for the LoadBalancer external IP to be assigned
- Replace
<BACKEND-EXTERNAL-IP>with that value - Then build and push the frontend image
If you build the frontend before the backend’s external IP is available, the deployed UI will point to an invalid endpoint.
Initialize color mapping, default color, and example notes:
onst TEAM_COLORS = {'UI': '#f59e0b','Platform': '#8b5cf6','Identity': '#3b82f6','Catalog': '#10b981','Commerce': '#ef4444','Data': '#06b6d4','Operations': '#f97316'};const DEFAULT_COLOR = '#6b7280';let cy = null;
Initialize Cytoscape:
function initGraph() {cy = cytoscape({container: document.getElementById('graph'),// Visual styling for nodes and edgesstyle: [{selector: 'node',style: {'background-color': 'data(color)','label': 'data(label)','width': 60,'height': 60,'text-valign': 'bottom','text-halign': 'center','text-margin-y': 5,'font-size': '12px','text-wrap': 'wrap','text-max-width': '100px','border-width': 2,'border-color': '#ffffff','overlay-opacity': 0}},{selector: 'edge',style: {'width': 2,'line-color': '#cbd5e1','target-arrow-color': '#cbd5e1','target-arrow-shape': 'triangle','curve-style': 'bezier','arrow-scale': 1.5,'label': 'data(relationship)','font-size': '10px','text-rotation': 'autorotate','text-margin-y': -10,'color': '#64748b'}},{selector: 'node:selected',style: {'border-width': 4,'border-color': '#2563eb'}}],// Automatic layout algorithmlayout: {name: 'breadthfirst',directed: true,padding: 50,spacingFactor: 1.5},// Enable zoom and panminZoom: 0.3,maxZoom: 3,wheelSensitivity: 0.2});// Show node details when clickedcy.on('tap', 'node', function(event) {const node = event.target;showNodeDetails(node.data());});// Hide details when clicking backgroundcy.on('tap', function(event) {if (event.target === cy) {hideNodeDetails();}});}
Create the node details panel functions:
function showNodeDetails(data) {document.getElementById('nodeName').textContent = data.label;document.getElementById('nodeTeam').textContent = data.team || 'Unknown';document.getElementById('nodeType').textContent = data.type || 'Unknown';document.getElementById('nodeDescription').textContent = data.description || 'No description';document.getElementById('nodeDetails').classList.add('active');}function hideNodeDetails() {document.getElementById('nodeDetails').classList.remove('active');}
Create the status panel functions:
function showStatus(message, type) {const statusEl = document.getElementById('status');statusEl.textContent = message;statusEl.className = `status ${type}`;statusEl.style.display = 'block';}function hideStatus() {document.getElementById('status').style.display = 'none';}
Create the generate map function:
async function generateMap() {const notes = document.getElementById('notes').value.trim();if (!notes) {showStatus('Please enter some architecture notes', 'error');return;}const generateBtn = document.getElementById('generateBtn');generateBtn.disabled = true;showStatus('Analyzing architecture notes...', 'loading');hideNodeDetails();try {// Call backend /map endpointconst response = await fetch(`${API_URL}/map`, {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ notes: notes })});if (!response.ok) {const error = await response.json();throw new Error(error.detail || 'Failed to generate map');}const systemMap = await response.json();// Render the graphrenderGraph(systemMap);showStatus(`Generated map with ${systemMap.nodes.length} components`, 'success');setTimeout(hideStatus, 3000);} catch (error) {showStatus(`Error: ${error.message}`, 'error');console.error('Map generation error:', error);} finally {generateBtn.disabled = false;}}
Create the render graph function:
function renderGraph(systemMap) {cy.elements().remove();const elements = [];systemMap.nodes.forEach(node => {elements.push({data: {id: node.id,label: node.label,team: node.team,type: node.type,description: node.description,color: TEAM_COLORS[node.team] || DEFAULT_COLOR}});});systemMap.edges.forEach(edge => {elements.push({data: {source: edge.source,target: edge.target,relationship: edge.relationship}});});cy.add(elements);cy.layout({name: 'breadthfirst',directed: true,padding: 50,spacingFactor: 1.5,animate: true,animationDuration: 500}).run();cy.fit(50);}
Configure event listeners and page initialization:
document.getElementById('generateBtn').addEventListener('click', generateMap);document.addEventListener('DOMContentLoaded', initGraph);
With that, your frontend is fully set up: the HTML provides the interface, Cytoscape visualizes the system graph, and app.js handles data fetching, graph rendering, and interactivity, ready to communicate with your backend /map endpoint.
Step 4: Dockerization
Backend Dockerfile:
# Use official Python runtime as base imageFROM python:3.11-slim# Set working directory inside containerWORKDIR /app# Copy requirements first (Docker layer caching optimization)COPY requirements.txt .# Install Python dependenciesRUN pip install --no-cache-dir -r requirements.txt# Copy application codeCOPY app.py .# Expose port 8000 for FastAPIEXPOSE 8000# Run the application# Uses uvicorn ASGI server for production-grade performanceCMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
Backend requirements.txt:
fastapi==0.115.0uvicorn[standard]==0.32.0httpx==0.24.1openai==1.3.0pydantic==2.9.2python-multipart==0.0.12
Frontend Dockerfile:
# Use nginx to serve static filesFROM nginx:alpine# Copy frontend files to nginx's default directoryCOPY index.html /usr/share/nginx/html/COPY app.js /usr/share/nginx/html/# Expose port 80EXPOSE 80# nginx starts automatically with the base image
Build and push images:
# Build backend imagedocker build -t YOUR_REGISTRY/system-mapper-backend:latest .# Push backend imagedocker push YOUR_REGISTRY/system-mapper-backend:latest# Build frontend imagedocker build -t YOUR_REGISTRY/system-mapper-frontend:latest .# Push frontend imagedocker push YOUR_REGISTRY/system-mapper-frontend:latest
Step 5: Deploy on Civo Kubernetes
Backend deployment YAML:
# ---------------------------------------------------------# Namespace# ---------------------------------------------------------apiVersion: v1kind: Namespacemetadata:name: system-mapper---# ---------------------------------------------------------# RelaxAI Secret (replace YOUR_API_KEY_HERE with actual key)# ---------------------------------------------------------apiVersion: v1kind: Secretmetadata:name: relaxai-secretnamespace: system-mappertype: OpaquestringData:api-key: "YOUR_API_KEY_HERE"---# ---------------------------------------------------------# Backend Deployment# ---------------------------------------------------------apiVersion: apps/v1kind: Deploymentmetadata:name: backendnamespace: system-mapperlabels:app: backendspec:replicas: 2selector:matchLabels:app: backendtemplate:metadata:labels:app: backendspec:containers:- name: backendimage: YOUR_REGISTRY/system-mapper-backend:latest # Replace with your imageports:- containerPort: 8000name: httpenv:- name: RELAXAI_API_KEYvalueFrom:secretKeyRef:name: relaxai-secretkey: api-keyresources:requests:memory: "512Mi"cpu: "500m"limits:memory: "1Gi"cpu: "1000m"livenessProbe:httpGet:path: /healthport: 8000initialDelaySeconds: 10periodSeconds: 30readinessProbe:httpGet:path: /healthport: 8000initialDelaySeconds: 5periodSeconds: 10---# ---------------------------------------------------------# Backend Service (LoadBalancer)# ---------------------------------------------------------apiVersion: v1kind: Servicemetadata:name: backend-servicenamespace: system-mapperspec:type: LoadBalancerselector:app: backendports:- protocol: TCPport: 8000targetPort: 8000
Apply it:
kubectl apply -f kubernetes/backend-deployment.yaml
Frontend deployment YAML:
# ---------------------------------------------------------# Frontend Deployment# ---------------------------------------------------------# Serves the static HTML/JS interface via nginxapiVersion: apps/v1kind: Deploymentmetadata:name: frontendnamespace: system-mapperlabels:app: frontendspec:replicas: 2selector:matchLabels:app: frontendtemplate:metadata:labels:app: frontendspec:containers:- name: frontendimage: YOUR_REGISTRY/system-mapper-frontend:latest # Replace with your imageports:- containerPort: 80name: httpresources:requests:memory: "128Mi"cpu: "100m"limits:memory: "256Mi"cpu: "200m"# Health check (nginx has a /health endpoint from the config)livenessProbe:httpGet:path: /port: 80initialDelaySeconds: 10periodSeconds: 30readinessProbe:httpGet:path: /port: 80initialDelaySeconds: 5periodSeconds: 10---# ---------------------------------------------------------# Frontend Service (LoadBalancer)# ---------------------------------------------------------# Exposes the frontend to the internetapiVersion: v1kind: Servicemetadata:name: frontend-servicenamespace: system-mapperspec:type: LoadBalancerselector:app: frontendports:- protocol: TCPport: 80 # External port (access via http://<EXTERNAL-IP>)targetPort: 80 # Container port
Apply it:
kubectl apply -f kubernetes/frontend-deployment.yaml
Verify deployments and get external IPs:
kubectl get pods -n system-mapperkubectl get svc -n system-mapper
Step 6: Test and Visualize
With both the backend and frontend deployed, it’s time to verify that your system mapper works as expected. You can do this by entering real architecture notes in the frontend input box and checking whether the generated system map appears correctly in the browser.
Use this text in the frontend input box:
Our e-commerce platform: React frontend (UI) calls API Gateway (Platform). Gateway routes to User Service (Identity) for authentication using PostgreSQL, Product Service (Catalog) managing inventory with MongoDB and Elasticsearch, and Order Service (Commerce) processing payments via Stripe with PostgreSQL. All services publish events to Kafka (Platform). Background workers include Order Processor (Commerce) reading RabbitMQ for async payment tasks, and Analytics Worker (Data) consuming Kafka events to load Snowflake warehouse. Order Service calls Product Service to verify stock before checkout. Admin Dashboard (Operations) connects directly to Order Service and Product Service for management tasks.
If the system is configured correctly, the backend will extract components and their relationships, normalize the data into the required JSON format, and the frontend will render an interactive system map.
This tutorial is small and focused, meant to serve as a solid starting point rather than a toy example. While the example inputs are simple, the same pipeline can handle longer documents, multiple texts at once, or integration into real-world applications. When scaling up like this, GPU acceleration helps keep processing fast and responsive. Since the cluster is already GPU-enabled, you can move from simple experiments to larger production workloads without changing the architecture.
Summary
You've just built a tool that solves a problem every growing team faces: keeping architecture knowledge accessible and up-to-date. By combining LLMs with Civo's GPU-ready infrastructure, you transformed unstructured text into interactive diagrams in seconds, eliminating manual diagramming, stale docs, and knowledge silos.
This pattern extends beyond system maps. Use the same approach for API dependency tracking, data flow visualization, or team responsibility matrices. Add authentication, save/load features, or integrate with your existing docs platform. The foundation is there; scale it as your needs grow.
Additional Resources
To learn more about the technologies used in this tutorial and explore next steps, check out these resources:

Software Engineer @ GoCardless
Mostafa Ibrahim is a software engineer and technical writer specializing in developer-focused content for SaaS and AI platforms. He currently works as a Software Engineer at GoCardless, contributing to production systems and scalable payment infrastructure.
Alongside his engineering work, Mostafa has written more than 200 technical articles reaching over 500,000 readers. His content covers topics including Kubernetes deployments, AI infrastructure, authentication systems, and retrieval-augmented generation (RAG) architectures.
Share this article