A self-hosted B2B sales pipeline CRM — track deals, proposals, accounts, stakeholders, communications, and follow-ups in one place. Includes optional AI-assisted data entry via OpenRouter, OpenAI, or DeepSeek.
pulse-crm/
├── main.py # ASGI entry point (imports app from app/main.py)
├── app/
│ ├── main.py # FastAPI app, router registration, DB init, lead source seed
│ ├── config.py # env-var config + session key management
│ ├── database.py # SQLAlchemy engine + session (SQLite)
│ ├── templates_factory.py # Singleton Jinja2Templates with label globals registered
│ ├── models/ # ORM models (all imported in __init__.py for create_all)
│ │ ├── vacancy.py # Deal (VacancyStatus, Priority enums)
│ │ ├── application.py # Proposal (ApplicationStatus enum)
│ │ ├── application_note.py # Proposal notes
│ │ ├── document.py # Sales asset (DocumentType enum)
│ │ ├── contact.py # Stakeholder
│ │ ├── communication.py # Communication log (CommType enum)
│ │ ├── reminder.py # Follow-up task (ReminderType enum)
│ │ ├── company.py # Account
│ │ ├── company_note.py # Account notes
│ │ ├── daily_routine.py # Sales routine log
│ │ ├── fit_analysis.py # AI fit/qualification analysis for a deal
│ │ ├── vacancy_site.py # Lead source
│ │ └── setting.py # AppSetting key/value store
│ ├── routers/ # FastAPI routers (one per resource)
│ │ ├── auth.py # login / logout / registration
│ │ ├── dashboard.py
│ │ ├── vacancies.py # Deals
│ │ ├── applications.py # Proposals (scoped to a deal)
│ │ ├── documents.py # Sales Assets
│ │ ├── contacts.py # Stakeholders
│ │ ├── communications.py # Communications (scoped to a deal)
│ │ ├── reminders.py # Follow-ups
│ │ ├── companies.py # Accounts
│ │ ├── daily_routine.py # Sales Routine tracker
│ │ ├── for_processing.py # Qualification Queue
│ │ ├── vacancy_sites.py # Lead Sources
│ │ ├── io_router.py # JSON import / export
│ │ ├── settings.py # App settings
│ │ └── ai.py # AI normalize + autofill endpoints
│ ├── services/
│ │ ├── stats.py # Dashboard stats + overdue count
│ │ ├── document_rendering.py
│ │ ├── pdf_generation.py # Playwright in-process PDF export
│ │ ├── settings_service.py # get/set AppSetting helpers + defaults
│ │ ├── ai_service.py # OpenRouter / OpenAI / DeepSeek API calls
│ │ ├── auth_service.py # bcrypt password helpers
│ │ ├── labels.py # Display label mappings for all enum types
│ │ └── io_schema.py # FieldSpec definitions + serialize/deserialize helpers
│ ├── middleware/auth.py # AuthMiddleware: enforces session auth on all non-/auth/* routes
│ ├── static/ # Shared CSS / JS assets
│ └── templates/ # Jinja2 HTML templates
│ ├── base.html # Shared layout, sidebar nav
│ ├── macros.html # Reusable form + badge macros
│ ├── pdf/ # PDF HTML templates
│ └── <resource>/ # list, detail, form per resource
├── data/ # SQLite DB + session key (git-ignored)
│ ├── crm.db
│ └── portfolio_demo_seed.json
├── scripts/
│ ├── seed_portfolio_demo.py # Portfolio demo seed script
│ └── start.sh # Run tests then start server
└── pyproject.toml
Stack: Python 3.12 · FastAPI · SQLAlchemy (SQLite) · Jinja2 server-rendered templates · Tailwind CSS (CDN) · Playwright/Chromium (in-process PDF export) · python-markdown (server-side) · marked.js (live editor preview) · bcrypt + itsdangerous (session auth) · httpx (AI provider API calls)
DB: SQLite file at data/crm.db, path overridable via DB_PATH env var. Tables are auto-created on startup (Base.metadata.create_all). No migration framework — drop the file and restart to reset.
URL structure:
| Prefix | Resource |
|---|---|
/ |
Redirects to Dashboard |
/dashboard |
Dashboard |
/vacancies |
Deals list / detail / form |
/applications |
Proposals (scoped to a deal) |
/documents |
Sales Assets library |
/contacts |
Stakeholders list / detail / form |
/communications |
Communications log (scoped to a deal) |
/reminders |
Follow-ups list / form |
/companies |
Accounts list / detail / form |
/for-processing |
Qualification Queue |
/vacancy-sites |
Lead Sources |
/daily-routine |
Sales Routine tracker |
/io |
Data Import / Export |
/settings |
App settings (markdown mode, AI config, workflows) |
/ai |
AI JSON API (normalize, autofill-company, models) |
Prerequisites: Docker with Compose.
# build and start (first run pulls ~1.5 GB image — Chromium is bundled)
docker compose up -d --build
# view logs
docker compose logs -f
# stop
docker compose downOpen http://localhost:8002. On first run you will be prompted to register a password.
All data (crm.db, session.key) is stored in ./data/ on the host — it survives container restarts and rebuilds. To move the app to another machine, copy the project folder including data/.
By default the container runs the automated test suite before starting the server. To skip startup tests when needed:
RUN_TESTS_ON_START=0 docker compose up -d # skip tests, no rebuild
RUN_TESTS_ON_START=0 docker compose up -d --build # skip tests + rebuild imagePrerequisites: uv.
# install dependencies
uv sync
# install Chromium for PDF export (one-time, ~110 MB)
uv run playwright install chromium
# run tests, then start the server
bash scripts/start.shOpen http://localhost:8000. On first run you will be prompted to register a password.
To use a custom DB path:
DB_PATH=/path/to/my.db uv run uvicorn main:app --reloadTo override the PDF export timeout (default 30 s):
PDF_TIMEOUT_SECONDS=60 uv run uvicorn main:app --reloadTo run in reload mode during development:
uv run pytest
uv run uvicorn main:app --reload --host 0.0.0.0 --port 8000To skip tests in the startup script:
RUN_TESTS_ON_START=0 bash scripts/start.shTo populate the app with realistic sample data for portfolio or demo purposes, run the seed script:
DB_PATH=data/crm.db uv run python scripts/seed_portfolio_demo.pyThe script is idempotent — running it again without flags does nothing if demo data is already present. To wipe and re-seed:
DB_PATH=data/crm.db uv run python scripts/seed_portfolio_demo.py --forceThe seed creates a representative set of accounts, stakeholders, deals (in various pipeline stages), proposals, communications, follow-ups, fit analyses, account notes, proposal notes, and daily routine logs. All dates are relative to today so the data always feels current.
The project uses pytest with FastAPI's TestClient. Tests live in tests/ and use an isolated SQLite database at /tmp/job-crm-tests.db, so they do not touch data/crm.db.
# run the full test suite
uv run pytest
# run a single file
uv run pytest tests/test_documents.pyCurrent coverage:
- Auth setup, login, invalid login, and protected route behavior
- Sales Asset CRUD, Markdown rendering helpers, PDF eligibility, and functional PDF download
- Deal creation, filtering, proposals, follow-up stats, dashboard rendering, and cascade delete
- JSON import/export success and invalid JSON handling
- AI normalize and autofill endpoints (guard logic, success paths, error handling, settings persistence)
- Daily routine toggle, day rollover archiving, and log clearing
- Settings: AI config, preview mode, daily routine steps, deal workflow steps
PDF tests assert functional behavior only: successful response, application/pdf, non-empty bytes, and download headers.
The central entity. Every proposal, communication, and follow-up belongs to a deal.
- Add a deal — click Deals → + New Deal. Fill in the account, role/service title, lead source URL, work mode, budget range, and deadline.
- Set status — use the status dropdown on the deal detail page or inline from the list. Pipeline stages:
New Lead→Qualify→Proposal Ready→Proposal Sent→Follow-up Scheduled→Negotiation→Won/Lost/Archived. - Priority —
Low/Medium/High, shown as a coloured badge. - AI fill — paste a raw brief or job description and click Fill with AI to auto-populate fields (requires AI enabled in Settings).
- Deal workflow checklist — a per-deal step-by-step workflow (configurable in Settings). Checking the first step on a new deal snapshots the current global workflow steps onto that deal so its checklist is independent of future workflow edits.
Each deal can have multiple proposals (e.g., revised offers after initial rejection).
- Open a deal and click New Proposal.
- Set the submission date, channel (LinkedIn / Email / Job Board / Direct / Referral / Agency), and status.
- Attach a proposal document and/or executive summary from your Sales Assets library using the type-in filter dropdowns. Click Create Proposal or Create Summary to create and pre-attach a new document in one step.
Research profiles for the companies you are targeting.
- Rich set of fields covering business overview, financials, tech stack, leadership, employee sentiment, and risk signals.
- AI autofill — type an account name and click Autofill with AI to search the web and populate all fields automatically (uses Perplexity Sonar on OpenRouter by default, falls back to the selected model).
- AI normalize — paste raw text (LinkedIn page, job post, etc.) and click Fill with AI to extract structured fields.
- JSON import — paste a JSON blob into the form to bulk-fill all fields at once.
- Search accounts by name, industry, or description from the list view.
A Markdown-based library for proposals, executive summaries, and outreach messages.
- Types:
Master Proposal Template,Service-Line Proposal,Client-Specific Proposal,Executive Summary,Outreach Message,Follow-up Message - The editor supports Split View (edit + live preview side-by-side) and Preview Only mode.
- Service-Line and Client-Specific Proposals can reference a Parent Template to track lineage.
- Click Download PDF on eligible proposal and executive summary documents to download a formatted PDF.
People you are in contact with or plan to reach out to.
- Link stakeholders to one or more deals from the stakeholder detail page.
- Relationship strength:
Weak/Moderate/Strong. - Communications can optionally reference a stakeholder.
- AI fill — paste raw contact info and click Fill with AI to auto-populate fields.
A log of every interaction for a deal (emails, LinkedIn messages, discovery call notes).
- Types:
Proposal Sent,LinkedIn Outreach,Email,Follow-up,Client Reply,Deal Lost,Meeting Scheduled,Discovery Completed,Internal Note - Fields: date, summary, full text, outcome, next step.
Time-based tasks tied to a deal or stakeholder.
- Types:
Follow-up,Send Proposal,Meeting Prep,Send Sales Asset,Send Connection Request,Send First Message,Check Reply,Ask for Introduction,Reconnect Later,Other - Overdue follow-ups (due date in the past, not completed) are counted in the sidebar badge.
- Mark a follow-up complete from the list or detail page.
A focused view of deals flagged for qualification review.
- Mark any deal as Add to Queue from the deal detail page.
- Set a processing date to schedule when to review the deal.
- Work through queued deals to quickly decide whether to advance or drop each opportunity.
A curated list of channels where you find and develop new business.
- Categories:
Owned Channel(your profiles and inbound),Prospecting(active outbound search),Partner / Marketplace(referral networks and agency channels). - Each source has a Priority (
Critical/High/Medium/Low) and a custom display order within its category. - Pre-seeded with 12 sources including Upwork, LinkedIn Sales Navigator, Clutch, Contra, and a Referral Network entry.
A daily habit tracker for pipeline-building activities.
- The routine steps are configurable in Settings → Sales Routine.
- Each day's progress is tracked separately. Completed steps are archived at midnight (on the first page visit after rollover) so you can review historical completion rates.
- Click any step to toggle it done/undone for today.
Shows active deal counts by pipeline stage, conversion rates (Reply Rate, Negotiation Rate, Win Rate), overdue follow-ups, and a Sales Routine progress widget at a glance.
Accessible from the sidebar. Sections:
- Markdown Preview — set the default editor mode (
editororpreview). - AI Integration — enable/disable AI, connect providers, select the active model, and optionally override system prompts. See AI Integration.
- Sales Routine — edit the ordered list of daily steps (JSON array).
- Deal Workflow — edit the ordered checklist steps applied to new deals (JSON array).
- Data Management — view record counts per table and clear individual tables.
All AI features are opt-in and require at least one provider API key configured in Settings. The app supports three providers simultaneously — switch between them without re-entering keys.
| Provider | Models | Pricing shown in dropdown |
|---|---|---|
| OpenRouter | Any model on the OpenRouter marketplace, including free models | Live from API |
| OpenAI | GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo, o1, o1-mini, o3-mini, and all versioned variants | Hardcoded from published pricing |
| DeepSeek | All DeepSeek chat models | From API if available |
- Go to Settings → AI Integration.
- Toggle Enable AI features.
- Enter API keys for whichever providers you want. Each key is saved independently.
- Click Connect for each provider to verify the key and load the model list.
- Select the active provider and model from the dropdowns. Token prices are shown where available.
- Save. AI buttons will appear on Account, Deal, and Stakeholder forms.
| Feature | Where | What it does |
|---|---|---|
| Normalize | Deal form, Stakeholder form, Account form | Paste raw text (brief, LinkedIn profile, etc.) → AI extracts and fills structured fields |
| Autofill | Account form | Type an account name → AI searches the web and populates all account fields |
Both operations use default system prompts that can be overridden in Settings → AI Integration → Advanced. Leaving a field blank restores the built-in default.
- Normalize prompt — controls how raw text is parsed into structured fields.
- Autofill prompt — controls how the model searches and summarizes account information.
- Autofill on OpenRouter automatically uses
perplexity/sonar(web-search capable) regardless of the selected model, then falls back to the selected model if Sonar fails. On OpenAI and DeepSeek it uses the selected model directly. - Normalize always uses the selected model.
| Method | Path | Description |
|---|---|---|
GET |
/ai/models?provider=<provider>&api_key=<key> |
Fetch available models with pricing |
POST |
/ai/normalize |
Extract structured fields from raw text |
POST |
/ai/autofill-company |
Search the web and populate account fields |
Normalize request body:
{ "entity_type": "vacancy", "raw_text": "Senior Engineer at Acme..." }entity_type is one of vacancy, contact, company.
Autofill request body:
{ "query": "Stripe" }Both endpoints return {"fields": {...}} on success or {"error": "..."} with an appropriate HTTP status on failure. AI must be enabled and an API key for the active provider must be saved in Settings, otherwise the endpoint returns 400.
Token prices are resolved from a built-in lookup table using the model ID prefix (USD per 1M tokens, input / output):
| Model prefix | Input | Output |
|---|---|---|
gpt-4o-mini |
$0.15 | $0.60 |
gpt-4o |
$2.50 | $10.00 |
gpt-4-turbo |
$10.00 | $30.00 |
gpt-4 |
$30.00 | $60.00 |
gpt-3.5-turbo |
$0.50 | $1.50 |
o1 |
$15.00 | $60.00 |
o1-mini |
$1.10 | $4.40 |
o3-mini |
$1.10 | $4.40 |
Versioned model IDs (e.g. gpt-4o-2024-11-20) inherit the price of their matching prefix. Non-chat models are filtered out of the dropdown entirely.
The DB is a single SQLite file. To inspect or modify it directly:
# open an interactive SQL shell
sqlite3 data/crm.db
# useful queries
.tables
.schema vacancies
SELECT id, company, role_title, status FROM vacancies;
SELECT * FROM reminders WHERE completed = 0 AND due_date < date('now');To back up the database:
cp data/crm.db data/crm-backup-$(date +%F).dbTo reset (wipe all data):
rm data/crm.db
# restart the app — tables are recreated automaticallyGo to Data Import / Export in the sidebar.
- Export — downloads a
crm-export-YYYY-MM-DD.jsonfile with all accounts, deals, stakeholders, and sales assets. - Import — upload a JSON file in the same format. Unknown or invalid records are skipped silently; a count of imported records is shown after.
{
"companies": [
{
"name": "Acme Corp",
"website": "https://acme.com",
"description": "B2B SaaS platform",
"industry": "Software",
"business_model": "SaaS",
"target_customers": "Mid-market",
"tech_stack": "Python, React",
"headquarters": "Berlin, Germany",
"company_size": "50-200",
"notes": "Warm intro via referral"
}
],
"vacancies": [
{
"company": "Acme Corp",
"role_title": "Backend Automation Sprint",
"source": "Upwork",
"url": "https://upwork.com/jobs/...",
"location": "Remote",
"work_mode": "Remote",
"employment_type": "Contract",
"salary_min": 5000,
"salary_max": 8000,
"salary_currency": "USD",
"date_found": "2026-05-01",
"deadline": "2026-05-15",
"status": "Applied",
"priority": "High",
"vacancy_notes": "Strong Python focus, fast turnaround",
"fit_notes": "Good match on automation experience",
"applications": [
{
"application_date": "2026-05-05",
"channel": "Direct",
"positioning_angle": "Led similar migration at previous client",
"notes": "Sent tailored proposal",
"status": "Submitted"
}
],
"communications": [
{
"comm_date": "2026-05-07",
"comm_type": "Reply Received",
"summary": "Client asked for discovery call",
"outcome": "Call booked for May 10",
"next_step": "Prepare demo"
}
],
"reminders": [
{
"due_date": "2026-05-10",
"task_type": "Interview Prep",
"note": "Review client's existing stack",
"completed": false
}
]
}
],
"contacts": [
{
"full_name": "Jane Smith",
"role_title": "Head of Engineering",
"company": "Acme Corp",
"linkedin_url": "https://linkedin.com/in/janesmith",
"email": "jane@acme.com",
"notes": "Decision maker, prefers async comms",
"relationship_strength": "Moderate"
}
],
"documents": [
{
"title": "Master Proposal Template 2026",
"doc_type": "Master CV",
"content_markdown": "# Andrew Legovich\n\n## Services\n...",
"notes": "Base template, update quarterly"
}
]
}Enum values reference (these are the raw values used in JSON — the UI shows commercial display labels):
| Field | Accepted values |
|---|---|
vacancy.status |
New, To Review, Ready to Apply, Applied, Follow-Up Planned, Interviewing, Rejected, Offer, Closed / Archived |
vacancy.priority |
Low, Medium, High |
application.status |
Draft, Submitted, Acknowledged, In Review, Interview Scheduled, Interviewed, Rejected, Offer, Withdrawn |
application.channel |
LinkedIn, Email, Job Board, Direct, Referral, Agency |
communication.comm_type |
Application Submitted, LinkedIn Message, Email, Follow-up, Reply Received, Rejection, Interview Scheduled, Interview Completed, Internal Note |
reminder.task_type |
Follow-up, Apply, Interview Prep, Send Documents, Send connection request, Send first message, Check reply, Ask for referral, Reconnect later, Other |
document.doc_type |
Master CV, Role-specific CV, Tailored CV, Cover Letter, Outreach Message, Follow-up Message |
contact.relationship_strength |
Weak, Moderate, Strong |
This project is licensed under the Apache License 2.0. See the LICENSE file for details.
| Library | License |
|---|---|
| FastAPI | MIT |
| SQLAlchemy | MIT |
| Jinja2 | BSD-3-Clause |
| Playwright | Apache 2.0 |
| httpx | BSD-3-Clause |
| uvicorn | BSD-3-Clause |
| python-markdown | BSD-3-Clause |
| itsdangerous | BSD-3-Clause |
| bcrypt | Apache 2.0 |
| python-multipart | Apache 2.0 |
| python-slugify | MIT |
All third-party components are subject to their respective licenses. This project optionally integrates with external AI services (OpenRouter, OpenAI, DeepSeek); usage of those services is subject to their own terms.