# Claude-Powered Chat Widget: FastAPI + Railway + Obsidian Publish
>**Date:** 20 May, 2026
>**Series:** Personal Site Enhancements
>**Focus:** Deploying an LLM-backed chat widget on a static Obsidian Publish site
>**Environment:** Obsidian Publish (custom domain) + Railway (cloud-hosted Python backend)
---
This guide walks through how I built a floating AI chat widget for my Obsidian Publish site, powered by Claude (Anthropic), with a lightweight Python backend hosted on Railway.
No frameworks, no complex infrastructure. Just a few clean files and a straightforward deploy.
---
## What We're Building
```
Visitor types a question in the chat widget
↓
publish.js sends it to your FastAPI backend on Railway
↓
FastAPI calls Claude with a system prompt describing who you are
↓
Claude's answer is returned and displayed in the widget window
```
---
## Stack
| Layer | Tool |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Frontend | `publish.js` — vanilla JS injected into Obsidian Publish |
| Backend | FastAPI (Python) |
| LLM | Claude Haiku via Anthropic API |
| Hosting | Railway |
| Notifications | Pushover *(upcoming feature)* — delivers visitor contact details and unanswered questions directly to my phone, enabling personal outreach |
---
## Prerequisites
Before starting, make sure you have:
- An **Obsidian Publish** site on a **custom domain** (`publish.js` only executes on custom domains, not on `publish.obsidian.md`)
- A **GitHub** account
- A **Railway** account ([railway.app](https://railway.app)) — free tier is sufficient
- An **Anthropic API key** ([console.anthropic.com](https://console.anthropic.com))
---
## Project Structure
```
obsidian-chat/
├── backend/
│ ├── main.py ← FastAPI app + Claude integration
│ ├── requirements.txt ← Python dependencies
│ └── Procfile ← Tells Railway how to start the app
└── frontend/
└── publish.js ← Chat widget, lives in your vault root
```
---
## Step 1 — Build the Backend
### `requirements.txt`
```
fastapi==0.115.0
uvicorn[standard]==0.30.6
anthropic==0.40.0
pydantic==2.9.2
requests==2.32.3
```
| Package | Purpose |
|---|---|
| `fastapi` | The web framework that handles incoming HTTP requests and routes them to your functions |
| `uvicorn` | The server that actually runs FastAPI — Railway starts this via the Procfile |
| `anthropic` | Anthropic's official Python SDK for calling the Claude API |
| `pydantic` | Validates the shape of incoming request data (e.g. ensures `message` is a string) |
| `requests` | Standard Python library for making outbound HTTP calls (used for Pushover notifications — upcoming) |
### `Procfile`
```
web: uvicorn main:app --host 0.0.0.0 --port $PORT
```
Railway reads this to know how to start your app. `$PORT` is injected automatically — you never set this yourself.
### `main.py`
```python
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import anthropic
import os
ALLOWED_ORIGIN = "https://yourdomain.com" # ← your Obsidian Publish domain
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[ALLOWED_ORIGIN],
allow_methods=["POST"],
allow_headers=["*"],
)
# Fill this in with your own information.
# This is what Claude reads to answer questions about you.
SYSTEM_PROMPT = """You are a friendly assistant on a personal website. Answer questions
about the site owner using ONLY the information below. Keep answers concise (2-4 sentences).
If asked something not covered here, say you don't have that information and suggest
they reach out directly.
--- ABOUT ME ---
Name: [Your Name]
Role: [Your Role]
Background: [2-3 sentences about yourself]
Skills: [Key skills]
Projects: [Notable projects]
Contact: [Your preferred contact method]
--- END ---
"""
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
class ChatRequest(BaseModel):
message: str
history: list[dict] = []
@app.post("/chat")
async def chat(req: ChatRequest, request: Request):
# Only accept requests originating from your site
origin = request.headers.get("origin", "")
if origin != ALLOWED_ORIGIN:
raise HTTPException(status_code=401, detail="Unauthorized")
if not req.message.strip():
raise HTTPException(status_code=400, detail="Message cannot be empty")
messages = req.history[-10:]
messages.append({"role": "user", "content": req.message})
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=300,
system=SYSTEM_PROMPT,
messages=messages,
)
return {"reply": response.content[0].text}
@app.get("/health")
async def health():
return {"status": "ok"}
```
**Two things to customize before deploying:**
1. Set `ALLOWED_ORIGIN` to your actual domain
2. Fill in the `SYSTEM_PROMPT` block with your own information
### What each part of `main.py` does
**`ALLOWED_ORIGIN` + CORS middleware**
Restricts which domain is allowed to call this API. CORS is enforced by browsers — it prevents JavaScript on other sites from reading your endpoint's responses. The Origin header check inside `/chat` adds a second layer for the same reason.
**`SYSTEM_PROMPT`**
The instructions and personal information you give Claude. This is what the bot reads when forming answers. Everything between `--- ABOUT ME ---` and `--- END ---` is your bio — Claude will only answer questions based on what you put here.
**`client = anthropic.Anthropic(...)`**
Creates a reusable connection to the Anthropic API, authenticated using your API key pulled from Railway's environment variables. This runs once at startup, not on every request.
**`class ChatRequest`**
Defines the shape of data that `publish.js` sends with each message — a `message` string and an optional `history` list of prior messages. Pydantic validates this automatically and rejects malformed requests before they reach your logic.
**`async def chat(...)`**
The main endpoint. It runs three checks before calling Claude: verifies the request came from your domain, confirms the message isn't empty, then trims conversation history to the last 10 messages (5 back-and-forth turns) to keep context manageable. It then calls Claude and returns the reply.
**`async def health()`**
A simple ping endpoint. Visit `/health` in a browser to confirm your Railway deployment is running. Returns `{"status": "ok"}` — nothing more.
---
## Step 2 — Build the Frontend
Create `publish.js` in your **vault root** using VSCode or Finder/Explorer — not inside Obsidian itself (Obsidian appends `.md` to any file it creates, which breaks it).
The full `publish.js` source is available on GitHub: [publish.js](https://github.com/epemb/ai_chatbot_widget/blob/02e96e5ce58dff475116e0e1f7d32ce5c2a5bf2e/frontend/publish.js)
Before placing it in your vault, update the two config values at the top of the file:
```js
const BACKEND_URL = "https://your-app.up.railway.app"; // ← your Railway URL
const GREETING = "Hi! Ask me anything about this site's author.";
```
---
## Step 3 — Deploy to Railway
1. Push your `backend/` folder to a **private** GitHub repo
2. Go to [railway.app](https://railway.app) → **New Project → Deploy from GitHub**
3. Select your repo — Railway auto-detects `Procfile` and `requirements.txt` and starts building
4. Go to the **Variables** tab and add:
```
ANTHROPIC_API_KEY = sk-ant-...
```
5. Go to **Settings → Networking → Generate Domain** to get your public URL
### Verify it's working
```bash
curl https://your-app.up.railway.app/health
# → {"status": "ok"}
```
Then test the chat endpoint:
```bash
curl -X POST https://your-app.up.railway.app/chat \
-H "Content-Type: application/json" \
-d '{"message": "Who are you?"}'
```
If you get a reply back, your backend is live.
---
## Step 4 — Publish the Widget
1. Open your Obsidian vault folder in **VSCode**
2. Confirm `publish.js` is at the root level alongside your other folders
3. Open Obsidian → click the **Publish icon** → find `publish.js` in the changes list → **Publish**
4. Hard refresh your site (`Cmd+Shift+R` / `Ctrl+Shift+R`)
A chat button should now appear in the bottom-right corner of every page.
---
## Cost
Claude Haiku at `max_tokens=300` costs roughly **$0.001 per message** — essentially free for a personal site. Set a spend limit at [console.anthropic.com](https://console.anthropic.com) as an absolute backstop.