# 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.