
Building a Claude Agent on Railway + Supabase in 20 Minutes
Building a Claude Agent on Railway + Supabase in 20 Minutes
Goal: Deploy a production Claude agent that can:
- Answer customer questions
- Look up data from Supabase
- Handle conversations with memory
- Scale automatically
Time: 20 minutes Cost: ~$5/month for light usage Stack: Python + Claude + Railway + Supabase
Let's build it.
Prerequisites
You need:
- Anthropic API key (get free credits at console.anthropic.com)
- GitHub account
- Railway account (railway.app - free tier available)
- Supabase account (supabase.com - free tier available)
Optional but helpful:
- Basic Python knowledge
- Familiarity with REST APIs
Step 1: Set Up Supabase Database (3 minutes)
Create Project
- Go to supabase.com
- Click "New Project"
- Name:
claude-agent-demo - Database password: (generate strong password)
- Region: Choose closest to you
- Click "Create new project"
Wait ~2 minutes for provisioning.
Create Table
In Supabase dashboard:
- Go to "Table Editor"
- Click "New Table"
- Name:
products - Columns:
id (int8, primary key, auto-increment) name (text) price (float8) stock (int4) description (text) created_at (timestamptz, default: now()) - Click "Save"
Insert Sample Data
In SQL Editor:
INSERT INTO products (name, price, stock, description) VALUES
('Laptop Pro', 1299.99, 15, 'High-performance laptop with 16GB RAM'),
('Wireless Mouse', 29.99, 50, 'Ergonomic wireless mouse with USB receiver'),
('USB-C Cable', 12.99, 100, '6ft braided USB-C charging cable'),
('Desk Lamp', 39.99, 25, 'Adjustable LED desk lamp with touch control'),
('Webcam HD', 79.99, 30, '1080p webcam with built-in microphone');
Get API Credentials
- Go to "Settings" → "API"
- Copy:
- Project URL (something like
https://xxx.supabase.co) anon/publickey
- Project URL (something like
Save these for later.
Step 2: Create Claude Agent (5 minutes)
Project Structure
Create new directory:
mkdir claude-agent
cd claude-agent
Install Dependencies
Create requirements.txt:
anthropic==0.18.1
supabase==2.3.4
flask==3.0.2
python-dotenv==1.0.1
Create Agent Code
Create agent.py:
import os
from anthropic import Anthropic
from supabase import create_client, Client
class ClaudeAgent:
def __init__(self):
# Initialize Claude
self.anthropic = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
# Initialize Supabase
supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_KEY")
self.supabase: Client = create_client(supabase_url, supabase_key)
# Conversation history (in-memory for demo, use DB for production)
self.conversations = {}
def search_products(self, query: str):
"""Search products in database"""
try:
response = self.supabase.table("products").select("*").ilike("name", f"%{query}%").execute()
return response.data
except Exception as e:
return {"error": str(e)}
def get_product_details(self, product_id: int):
"""Get specific product details"""
try:
response = self.supabase.table("products").select("*").eq("id", product_id).single().execute()
return response.data
except Exception as e:
return {"error": str(e)}
def check_stock(self, product_id: int):
"""Check product stock level"""
try:
response = self.supabase.table("products").select("name, stock").eq("id", product_id).single().execute()
return response.data
except Exception as e:
return {"error": str(e)}
def get_tools(self):
"""Define available tools for Claude"""
return [
{
"name": "search_products",
"description": "Search for products by name. Returns list of matching products with id, name, price, stock, and description.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (product name)"
}
},
"required": ["query"]
}
},
{
"name": "get_product_details",
"description": "Get detailed information about a specific product by ID.",
"input_schema": {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "Product ID"
}
},
"required": ["product_id"]
}
},
{
"name": "check_stock",
"description": "Check current stock level for a product.",
"input_schema": {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "Product ID"
}
},
"required": ["product_id"]
}
}
]
def execute_tool(self, tool_name: str, tool_input: dict):
"""Execute a tool and return result"""
if tool_name == "search_products":
return self.search_products(tool_input["query"])
elif tool_name == "get_product_details":
return self.get_product_details(tool_input["product_id"])
elif tool_name == "check_stock":
return self.check_stock(tool_input["product_id"])
else:
return {"error": f"Unknown tool: {tool_name}"}
def chat(self, user_message: str, conversation_id: str = "default"):
"""Main chat function with tool use"""
# Get or create conversation history
if conversation_id not in self.conversations:
self.conversations[conversation_id] = []
# Add user message
self.conversations[conversation_id].append({
"role": "user",
"content": user_message
})
# Keep conversation history lean (last 10 messages)
messages = self.conversations[conversation_id][-10:]
# Initial API call
response = self.anthropic.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
tools=self.get_tools(),
messages=messages
)
# Tool use loop
while response.stop_reason == "tool_use":
# Extract tool use
tool_use = next(block for block in response.content if block.type == "tool_use")
# Execute tool
tool_result = self.execute_tool(tool_use.name, tool_use.input)
# Add assistant response and tool result to history
self.conversations[conversation_id].append({
"role": "assistant",
"content": response.content
})
self.conversations[conversation_id].append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": str(tool_result)
}
]
})
# Continue conversation
messages = self.conversations[conversation_id][-10:]
response = self.anthropic.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
tools=self.get_tools(),
messages=messages
)
# Extract final text response
final_text = next(
(block.text for block in response.content if hasattr(block, "text")),
"I couldn't generate a response."
)
# Add assistant response to history
self.conversations[conversation_id].append({
"role": "assistant",
"content": final_text
})
return final_text
Create API Server
Create app.py:
import os
from flask import Flask, request, jsonify
from dotenv import load_dotenv
from agent import ClaudeAgent
# Load environment variables
load_dotenv()
# Initialize Flask
app = Flask(__name__)
# Initialize agent
agent = ClaudeAgent()
@app.route("/")
def home():
return {"status": "Claude Agent API is running"}
@app.route("/health")
def health():
return {"status": "healthy"}
@app.route("/chat", methods=["POST"])
def chat():
"""Chat endpoint"""
try:
data = request.get_json()
user_message = data.get("message")
conversation_id = data.get("conversation_id", "default")
if not user_message:
return jsonify({"error": "Message required"}), 400
# Get response from agent
response = agent.chat(user_message, conversation_id)
return jsonify({
"response": response,
"conversation_id": conversation_id
})
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == "__main__":
port = int(os.getenv("PORT", 8080))
app.run(host="0.0.0.0", port=port)
Create Environment File
Create .env:
ANTHROPIC_API_KEY=your_anthropic_key_here
SUPABASE_URL=your_supabase_url_here
SUPABASE_KEY=your_supabase_anon_key_here
PORT=8080
Step 3: Test Locally (2 minutes)
Install Dependencies
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt
Run Server
python app.py
Server runs on http://localhost:8080
Test with cURL
curl -X POST http://localhost:8080/chat \
-H "Content-Type: application/json" \
-d '{"message": "Do you have any laptops in stock?"}'
Expected response:
{
"response": "Yes! We have the Laptop Pro in stock. It's a high-performance laptop with 16GB RAM, priced at $1299.99. We currently have 15 units available.",
"conversation_id": "default"
}
Works? Great! Let's deploy.
Step 4: Deploy to Railway (5 minutes)
Prepare for Deployment
Create Procfile:
web: python app.py
Create runtime.txt:
python-3.11.6
Create .gitignore:
venv/
__pycache__/
.env
*.pyc
Initialize Git
git init
git add .
git commit -m "Initial commit: Claude agent"
Push to GitHub
- Create new repo on GitHub
- Push code:
git remote add origin https://github.com/yourusername/claude-agent.git
git branch -M main
git push -u origin main
Deploy on Railway
- Go to railway.app
- Click "New Project"
- Select "Deploy from GitHub repo"
- Choose your
claude-agentrepo - Railway auto-detects Python and deploys
Add Environment Variables
In Railway dashboard:
- Go to your project
- Click "Variables"
- Add:
ANTHROPIC_API_KEY=your_key SUPABASE_URL=your_url SUPABASE_KEY=your_key PORT=8080 - Click "Deploy"
Wait ~2 minutes for deployment.
Get Public URL
- In Railway, go to "Settings"
- Click "Generate Domain"
- Copy domain (something like
claude-agent-production.up.railway.app)
Step 5: Test Production (2 minutes)
Test Health Endpoint
curl https://your-railway-domain.up.railway.app/health
Should return:
{"status": "healthy"}
Test Chat Endpoint
curl -X POST https://your-railway-domain.up.railway.app/chat \
-H "Content-Type: application/json" \
-d '{
"message": "What products do you have under $50?",
"conversation_id": "user_123"
}'
Step 6: Add Conversation Persistence (Bonus)
Right now, conversations are in-memory. Let's persist them in Supabase.
Create Conversations Table
In Supabase SQL Editor:
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id TEXT NOT NULL,
messages JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_conversation_id ON conversations(conversation_id);
Update Agent Code
In agent.py, replace the __init__ and chat methods:
def __init__(self):
# ... existing code ...
# Remove in-memory storage
# self.conversations = {}
def load_conversation(self, conversation_id: str):
"""Load conversation from Supabase"""
try:
response = self.supabase.table("conversations").select("messages").eq("conversation_id", conversation_id).single().execute()
return response.data["messages"]
except:
return []
def save_conversation(self, conversation_id: str, messages: list):
"""Save conversation to Supabase"""
try:
# Check if conversation exists
existing = self.supabase.table("conversations").select("id").eq("conversation_id", conversation_id).execute()
if len(existing.data) > 0:
# Update
self.supabase.table("conversations").update({
"messages": messages,
"updated_at": "now()"
}).eq("conversation_id", conversation_id).execute()
else:
# Insert
self.supabase.table("conversations").insert({
"conversation_id": conversation_id,
"messages": messages
}).execute()
except Exception as e:
print(f"Error saving conversation: {e}")
def chat(self, user_message: str, conversation_id: str = "default"):
"""Main chat function with persistent conversation history"""
# Load conversation history from database
messages = self.load_conversation(conversation_id)
# Add user message
messages.append({
"role": "user",
"content": user_message
})
# ... rest of existing chat logic ...
# After getting final response, save conversation
self.save_conversation(conversation_id, messages)
return final_text
Commit and push:
git add .
git commit -m "Add conversation persistence"
git push
Railway auto-deploys the update.
What You Built
A production Claude agent that:
- ✅ Uses Claude 3.5 Sonnet with tool use
- ✅ Queries Supabase database
- ✅ Maintains conversation history
- ✅ Scales automatically on Railway
- ✅ Costs ~$5/month for light usage
- ✅ Has persistent conversations
Next Steps
Add Authentication
from functools import wraps
def require_api_key(f):
@wraps(f)
def decorated(*args, **kwargs):
api_key = request.headers.get("X-API-Key")
if api_key != os.getenv("API_KEY"):
return jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs)
return decorated
@app.route("/chat", methods=["POST"])
@require_api_key # Add this
def chat():
# ... existing code
Add Rate Limiting
pip install flask-limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
app=app,
key_func=get_remote_address,
default_limits=["100 per hour"]
)
@app.route("/chat", methods=["POST"])
@limiter.limit("10 per minute") # Add this
def chat():
# ... existing code
Add Monitoring
Railway provides basic metrics. For more, add:
pip install sentry-sdk
import sentry_sdk
sentry_sdk.init(dsn=os.getenv("SENTRY_DSN"))
Scale Up
Railway auto-scales, but you can optimize:
- Use connection pooling for Supabase
- Add caching for common queries (Redis)
- Implement async endpoints (FastAPI instead of Flask)
Cost Breakdown
Monthly estimates (1,000 requests):
| Service | Cost |
|---|---|
| Railway (Hobby plan) | $5 |
| Supabase (Free tier) | $0 |
| Claude API (1K requests, 500K tokens) | $1.50 |
| Total | ~$6.50/month |
For 10,000 requests/month: ~$20-30
Common Issues
Issue #1: Railway Deployment Fails
Fix: Check runtime.txt has correct Python version
Issue #2: Agent Can't Connect to Supabase
Fix: Verify environment variables are set in Railway dashboard
Issue #3: Conversation Not Persisting
Fix: Check Supabase table exists and agent has correct permissions
Issue #4: Slow Responses
Fix:
- Check Railway logs for errors
- Optimize Supabase queries (add indexes)
- Reduce conversation history length
The Bottom Line
You just built a production Claude agent that:
- Runs in the cloud
- Uses real database
- Scales automatically
- Costs < $10/month
Total time: 20 minutes Total cost: ~$6.50/month Production ready: Yes (with auth + monitoring)
Getting Started
Clone starter code:
git clone https://github.com/domainlabs/claude-agent-starter
cd claude-agent-starter
# Follow README for setup
Need help customizing this for your use case? We've deployed dozens of Claude agents.
Related reading:
- Claude tool use docs: https://docs.anthropic.com/claude/docs/tool-use
- Railway docs: https://docs.railway.app
- Supabase docs: https://supabase.com/docs
About the Author
DomAIn Labs Team
The DomAIn Labs team consists of AI engineers, strategists, and educators passionate about demystifying AI for small businesses.