Sarcasm as a Service — an open-source, self-hostable AI humor API that generates a unique, witty response on every request.
Snark is an open-source, self-hostable REST API that serves AI-generated humor — roasts, excuses, corporate jargon, fortune cookies, and 30+ other endpoints. Deploy it in one command with Docker, or use the free public API. Every response is uniquely generated by an LLM. No two responses are ever the same.
- Zero authentication — fully public, no API keys needed
- Multi-provider — supports Groq (free), Gemini, and Claude with automatic fallback
- Rate limited — 50 requests/hour per IP
- One-command stack —
docker compose upbrings the API, PostgreSQL, and Redis together; nothing external to provision - Self-hosted — run the whole thing on your own infrastructure
- Docker & Docker Compose — the only requirement; PostgreSQL and Redis are bundled into the stack
- Python 3.12+ — optional, only for running tests or tooling outside Docker
git clone https://github.com/PramodTKodag/snark.git
cd snark
cp .env.example .envEdit .env and add your API key for at least one provider:
# Groq is free — get a key at https://console.groq.com/keys
GROQ_API_KEY=your-groq-api-key-heredocker compose --profile dev up --buildThe API will be available at http://localhost:8100.
# Get a creative excuse to say no
curl http://localhost:8100/v1/wit/say-no/
# Get roasted
curl http://localhost:8100/v1/wit/roast/dave/
# Explain quantum physics like you're 5
curl "http://localhost:8100/v1/wit/explain-like-im-5/?q=quantum+physics"
# Generate an honest git commit message
curl http://localhost:8100/v1/wit/commit-message/Every endpoint returns the same compact JSON shape:
response is the generated text, persona is the voice that produced it, and cached tells you whether it came from the 5-minute response cache.
All endpoints are GET requests under /v1/wit/. Most accept optional ?q=, ?mood=, ?length=, ?lang=, and ?stream= query parameters.
| Endpoint | Description |
|---|---|
/v1/wit/say-no/ |
Creative excuse to say no |
/v1/wit/random-excuse/ |
Random excuse generator |
/v1/wit/corporate-jargon/ |
Corporate buzzword generator |
/v1/wit/commit-message/ |
Honest git commit message |
/v1/wit/hot-take/ |
Spicy hot take |
/v1/wit/compliment/ |
Backhanded compliment |
/v1/wit/bug-blame/ |
Who to blame for the bug |
/v1/wit/roast/<name>/ |
Personalized roast |
/v1/wit/worth-it/?q= |
Decision oracle |
/v1/wit/explain-like-im-5/?q= |
ELI5 any topic |
| Endpoint | Description |
|---|---|
/v1/wit/pickup-line/ |
Tech pickup line |
/v1/wit/social-bio/ |
Social media bio generator |
/v1/wit/motivation/ |
Questionable motivation |
/v1/wit/fortune-cookie/ |
Fortune cookie message |
/v1/wit/name-suggestion/?q= |
Name suggestion for anything |
/v1/wit/standup-update/ |
Standup update generator |
/v1/wit/code-review/ |
Code review comment |
/v1/wit/meeting-excuse/ |
Meeting escape excuse |
/v1/wit/jargon-translator/?q= |
Translate jargon to plain English |
/v1/wit/incident-postmortem/ |
Incident postmortem generator |
| Endpoint | Description |
|---|---|
/v1/wit/tech-battle/?q= |
Tech vs tech showdown |
/v1/wit/rate-anything/?q= |
Rate anything out of 10 |
/v1/wit/horoscope/ |
Developer horoscope |
/v1/wit/tldr/?q= |
TL;DR any topic |
/v1/wit/interview-question/ |
Absurd interview question |
/v1/wit/honest-changelog/ |
Honest changelog entry |
/v1/wit/debug-story/ |
Debugging horror story |
/v1/wit/proverb/ |
Tech proverb |
| Endpoint | Description |
|---|---|
/v1/wit/personas/ |
List every available persona (slug, name, tone) |
/v1/wit/moods/ |
List accepted mood values |
/v1/wit/random/ |
Surprise me — random persona |
/v1/wit/roast-github/<username>/ |
Roast a public GitHub profile |
/v1/wit/stats/ |
Usage stats (totals and top personas) |
Add ?mood= to any endpoint to change the tone:
sarcastic, angry, funny, sad, excited, dramatic, passive-aggressive,
philosophical, wholesome, unhinged, dry, chaotic, chill, spicy, deadpan
Fetch this list programmatically from /v1/wit/moods/.
Example: curl "http://localhost:8100/v1/wit/motivation/?mood=passive-aggressive"
Add ?length= (short, medium, or long) to control response size, and ?lang= to get the response in another language:
curl "http://localhost:8100/v1/wit/hot-take/?q=pizza&lang=Spanish"
curl "http://localhost:8100/v1/wit/proverb/?q=coding&length=long"
Add ?stream=true to any endpoint and use curl -N to receive the response token-by-token as Server-Sent Events, instead of one JSON object:
curl -N "http://localhost:8100/v1/wit/hot-take/?q=tabs+vs+spaces&stream=true"
data: {"delta": "Forcing"}
data: {"delta": " everyone"}
data: {"delta": " to"}
...
data: {"persona": "The Hot Take Machine", "done": true}
data: [DONE]
Streaming bypasses the response cache, so streamed calls always hit the LLM. Omit stream for the default cached JSON response.
POST /v1/wit/batch/ runs up to 5 personas in a single request. Each item names a persona slug (see /v1/wit/personas/) plus the same optional q, mood, length, and lang fields. Results come back in order; a bad slug yields a per-item error without failing the rest.
curl -X POST "http://localhost:8100/v1/wit/batch/" \
-H "Content-Type: application/json" \
-d '{
"requests": [
{"persona": "roast", "q": "Dave"},
{"persona": "commit-message"},
{"persona": "hot-take", "q": "tabs vs spaces", "mood": "spicy"}
]
}'
Each item is a separate LLM call, so a batch is slower than a single request and counts as one hit against the rate limit.
POST /v1/wit/reply/ takes a social media post and returns one short, sarcastic reply to it — built for reply bots. The reply reacts to the post's actual content and is kept tweet-length by default (length defaults to short).
curl -X POST "http://localhost:8100/v1/wit/reply/" \
-H "Content-Type: application/json" \
-d '{"post": "Just shipped a feature with zero tests. What could go wrong?"}'
See docs/twitter-bot.md for a complete ~30-line Twitter reply bot using this endpoint.
| Endpoint | Description |
|---|---|
/v1/wit/health/live/ |
Liveness probe |
/v1/wit/health/ready/ |
Readiness probe |
/v1/wit/health/status/ |
Full health check |
/v1/wit/swagger/ |
Swagger UI |
/v1/wit/redoc/ |
ReDoc |
The repo ships a dependency-free CLI that wraps the API. Install the package and use the snark command:
pip install -e .
snark hot-take "tabs vs spaces"
snark roast Dave --mood spicy
snark hot-take "remote work" --stream
snark github torvalds
snark personas
snark stats
The first positional argument is a persona slug (or one of personas, stats, random, roast <name>, github <username>); the second is optional context. It points at http://localhost:8100 by default — override with --api or the SNARK_API_URL environment variable.
Snark ships an MCP server (snark-mcp, in mcp-server/) so AI assistants — Claude Code, Claude Desktop, Cursor, and other MCP clients — can call snark as native tools. It's a thin client over this same REST API: point it at a running instance with SNARK_API_URL.
It exposes a curated set of tools — snark_roast, snark_roast_github, snark_hot_take, snark_commit_message, snark_reply, snark_worth_it, snark_wit (any persona by slug), and snark_list_personas — so you can just ask "roast the GitHub user torvalds" in your assistant and it calls the right one.
Quickest path with Claude Code (per-project, private to you):
make mcp-install # build the server into mcp-server/.venv
claude mcp add snark -e SNARK_API_URL=http://localhost:8100 \
-- "$(pwd)/mcp-server/.venv/bin/snark-mcp"Restart the session and the tools are available. See mcp-server/README.md for Claude Desktop / Cursor configs, the make mcp / make mcp-http / make mcp-inspect run modes, and details.
snark/
├── base/ # Django config
└── wit/
├── models.py # Persona, ResponseLog, GenerationEvent
├── views.py # API endpoints
├── services.py # Orchestrator (caching, anti-repetition, fallback)
├── stats.py # usage / cost / reliability aggregation
├── pricing.py # per-model token pricing
├── providers/ # AI provider abstraction
│ ├── base.py # AIProvider interface
│ ├── registry.py # Provider registry with fallback
│ ├── groq_provider.py
│ ├── gemini_provider.py
│ └── claude_provider.py
└── management/ # seed / admin bootstrap / pricing / retention commands
Snark automatically falls back between AI providers. If the primary provider hits a content filter or rate limit, it retries with a softened prompt, then falls back to the next provider. Configure your preferred default in .env:
AI_DEFAULT_PROVIDER=groq # groq (free), gemini, or claude# Run tests
make test
# Run tests with coverage
make test-cov
# Format code
make format
# Lint
make lint
# Run migrations
make migrate
# Seed personas
make seed
# Django shell
make shellAll configuration is via environment variables. See .env.example for the full list.
| Variable | Default | Description |
|---|---|---|
AI_DEFAULT_PROVIDER |
groq |
AI provider: groq, gemini, or claude |
GROQ_API_KEY |
— | Groq API key (free at console.groq.com) |
GEMINI_API_KEY |
— | Google Gemini API key (optional) |
ANTHROPIC_API_KEY |
— | Anthropic API key (optional, paid) |
POSTGRES_DB |
snark |
PostgreSQL database name |
REDIS_DB |
9 |
Redis database number |
USE_PROXY_SSL_HEADER |
False |
Trust X-Forwarded-Proto when behind a TLS-terminating proxy |
NUM_PROXIES |
— | Trusted proxy count, so per-IP rate limiting reads the real client IP from X-Forwarded-For |
GUNICORN_WORKERS |
2 |
Gunicorn worker processes (prod only) |
GUNICORN_THREADS |
8 |
Threads per worker; threads serve concurrent streams (prod only) |
MAX_CONCURRENT_STREAMS |
6 |
Max concurrent SSE streams per worker; excess get a 503 (stream_capacity). 0 disables the cap |
ADMIN_ENABLED |
False |
Enable the opt-in Django admin UI |
ADMIN_URL |
admin/ |
Path the admin mounts at when enabled (change it) |
ADMIN_USERNAME / ADMIN_EMAIL / ADMIN_PASSWORD |
— | Optional superuser auto-bootstrap on startup |
PROVIDER_TOKEN_COST |
— (empty) | Optional price override for the dashboard cost estimate: provider:input:output per $1M (e.g. claude:1:5), or legacy provider:blended. Empty uses the vendored LiteLLM map (make update-pricing) |
RESPONSE_LOG_RETENTION_DAYS |
30 |
Days to keep raw request logs (ResponseLog); 0 keeps them forever |
GENERATION_EVENT_RETENTION_DAYS |
90 |
Days to keep reliability events (GenerationEvent); 0 keeps them forever |
LOG_INPUT_MODE |
redacted |
How to store user input: redacted (strip structured PII + truncate), none (don't store), or raw (verbatim) |
Behind a reverse proxy? Set
USE_PROXY_SSL_HEADER=TrueandNUM_PROXIES=<n>so HTTPS detection and per-IP rate limiting work correctly. Leave both unset for direct connections.
Gunicorn threads serve concurrent requests; a ?stream=true request pins a
thread for the whole LLM call (streaming bypasses the cache). Tune capacity per
deployment with GUNICORN_WORKERS and GUNICORN_THREADS. MAX_CONCURRENT_STREAMS
caps streams per worker so a burst of streams can't consume every thread and
starve normal JSON traffic — excess streams get a fast 503 with a
stream_capacity error and a Retry-After header (set it to 0 to disable the
cap). Keep it below GUNICORN_THREADS to leave threads free for JSON requests.
For heavy sustained streaming load, an async worker (gevent) or an ASGI server is
the future scale-up.
Snark is privacy-forward by default: bounded retention is on out of the box and raw user input is not stored verbatim.
- Bounded retention. Request logs (
ResponseLog) are kept 30 days and reliability events (GenerationEvent) 90 days by default. Tune withRESPONSE_LOG_RETENTION_DAYS/GENERATION_EVENT_RETENTION_DAYS(0= keep forever). - PII-minimized input.
LOG_INPUT_MODE=redacted(the default) strips structured PII (email, phone, credit-card, SSN-like) from stored input and truncates it. Setnoneto store nothing, orrawto store input verbatim (opt-in — users may paste PII). Redaction is dependency-light and catches structured identifiers only, not free-text names/addresses; Microsoft Presidio (NER) is an optional upgrade if you need that.
Pruning runs automatically on startup and can be run on demand or from cron:
make prune-logs DAYS=90 # apply a 90-day window to both tables
# cron (daily at 03:00): apply the configured retention windows
0 3 * * * cd /app/snark && python manage.py prune_logsStats and anti-repetition never read the input field, so redaction does not affect them.
Logs are human-readable plain text by default. Set LOG_FORMAT=json (with an
optional LOG_LEVEL, default INFO) to emit one JSON line per record for both
Django and app logs — structured so aggregators can filter by field. Recommended in
production.
Every generation also logs a structured generation event with provider,
model, split input_tokens/output_tokens (and tokens), latency_ms, and an
estimated cost_usd — a machine-parseable, per-request usage line. It is INFO
and privacy-safe: it never includes raw user input.
LOG_FORMAT=json # plain (default) | json — one JSON line per record
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERRORShip these JSON lines to Loki/Grafana (or any log aggregator) for dashboards and alerting on token usage, cost, latency, and error rates — no extra dependency, just structured stdout.
Snark ships an opt-in Django admin for managing personas and browsing usage — off by default so public deployments never expose a login.
Enable it per deployment in .env:
ADMIN_ENABLED=True
ADMIN_URL=manage-a1b2c3/ # change to a non-guessable path; keep the trailing slash
ADMIN_USERNAME=admin # optional: auto-bootstrap a superuser on startup
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=a-strong-password # min 12 chars
# PROVIDER_TOKEN_COST=claude:1:5 # optional override, "provider:input:output" per $1MCost pricing comes from a vendored copy of LiteLLM's
community-maintained model cost map (MIT), stored at snark/wit/pricing_data.json
and refreshable with make update-pricing. Tokens are priced per model, split
into input vs output. PROVIDER_TOKEN_COST is an optional override (empty by
default): provider:input:output per $1M (e.g. claude:1:5), or legacy
provider:blended applied to both. It remains an estimate — list prices,
cached/batch discounts aren't modeled. Both streamed and non-streamed responses
log split token usage. Rates are cached for the process lifetime, so restart the
app after make update-pricing. Responses logged before the token-split feature
carry no input/output split, so their estimated cost shows $0.
On startup the stack runs ensure_admin, which creates/updates the superuser
from those vars (a no-op when unset). Or create one manually:
make admin # interactive createsuperuser
make ensure-admin # from ADMIN_* env varsThe admin mounts at ADMIN_URL and includes an interactive analytics
dashboard (Chart.js) with usage, cost, latency, and reliability analytics —
KPI cards, time-series and provider-share charts, latency percentiles,
per-persona cost, provider/model breakdowns, reliability metrics (error rate,
provider-fallback frequency, content-filter rate) with recent-failure detail,
unused-persona detection, a recent-activity feed, DB/Redis health status, and
a configurable cost estimate. It also provides persona CRUD with bulk
activate/deactivate/duplicate/clear-cache actions, and read-only
response-log browsing with retention pruning. Prune logs from the CLI too:
make prune-logs DAYS=90Keep the admin behind a reverse proxy / IP allowlist on any internet-facing host.
Contributions are welcome. Please read CONTRIBUTING.md before submitting a pull request.
By contributing, you agree to the Contributor License Agreement.
snark is free and open source under the AGPL-3.0 license. Running the public API costs real money — every response is a live LLM call. If it made you laugh or saved you time, consider sponsoring its development:
Sponsorship helps keep the public instance running and funds new endpoints.
To report a security vulnerability, please email pramodkodag.dev@gmail.com. Do not open a public GitHub issue. See SECURITY.md for details.
Copyright (C) 2026 Pramod Kodag
This project is licensed under the GNU Affero General Public License v3.0. This means:
- You can use, modify, and distribute this software freely
- If you modify and deploy it as a network service, you must share your changes
- All derivative works must use the same license
Pramod Kodag — GitHub


