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 — Groq (free), Gemini, Claude, or any OpenAI-compatible endpoint (including a local Ollama), chosen in one config file, 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 - Observability-ready — structured JSON logs to any aggregator, plus an opt-in Loki + Alloy + Grafana + Prometheus stack (
--profile observability) with a token-guarded/metricsendpoint - 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/
# Roast any public URL
curl "http://localhost:8100/v1/wit/roast-url/?url=https://example.com"
# 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/roast-url/?url= |
Roast any public URL (SSRF-safe fetch + Open Graph) |
/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>, url <url>); 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_roast_url, 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.
config/
└── providers.yaml # AI providers: default, fallback order, models, endpoints
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
│ ├── config.py # Loads and validates config/providers.yaml
│ ├── registry.py # Provider registry with fallback
│ ├── openai_compatible.py # Groq, Ollama, OpenRouter, vLLM, ...
│ ├── gemini_provider.py
│ └── claude_provider.py
└── management/ # seed / admin bootstrap / pricing / retention commands
Providers live in config/providers.yaml, not in environment variables. The file ships in the Docker image. With --profile dev the repo is mounted, so editing it and restarting switches providers. With --profile prod you must rebuild the image or mount your own file (see below).
default: groq # answers requests
fallback: [groq, gemini, claude] # tried in order when the default fails
providers:
groq: # the key is the provider name shown in logs and stats
type: openai_compatible
url: https://api.groq.com/openai/v1
key: GROQ_API_KEY # the NAME of the env var holding the key
model: openai/gpt-oss-20b
extra_body: # merged into every request body
reasoning_effort: low
ollama:
type: openai_compatible
url: http://host.docker.internal:11434/v1
model: gemma3:27b
free: true # keyless and costs nothing
gemini:
type: gemini
key: GEMINI_API_KEY
model: gemini-2.0-flash
claude:
type: claude
key: ANTHROPIC_API_KEY
model: claude-haiku-4-5-20251001| Field | Applies to | Description |
|---|---|---|
default |
top level | Provider that answers requests. Must be defined under providers |
fallback |
top level | Ordered list of providers to try when the default fails. Optional; no repeats |
type |
every provider | openai_compatible, gemini, or claude |
model |
every provider | Model id (up to 100 characters) |
key |
every provider | Env var name in uppercase (^[A-Z][A-Z0-9_]*$, e.g. GROQ_API_KEY), never the key itself. Required for gemini and claude; omit it for a keyless local server |
url |
openai_compatible |
Required. Base URL of the OpenAI-compatible API, with no credentials, query or fragment. Must be https when the provider has a key |
extra_body |
openai_compatible |
Extra JSON fields sent verbatim in every request, e.g. reasoning_effort. Cannot set model, messages, stream, stream_options, max_tokens, temperature, or n |
price |
every provider | [input, output] USD per 1M tokens for the dashboard cost estimate |
free |
keyless providers | true makes the cost estimate 0. Cannot be combined with key or price |
Provider names (the keys under providers) must match ^[a-z][a-z0-9_-]{0,49}$. The file is validated at startup; a bad file stops the app with an error naming the provider and field.
Use your own file. Point AI_PROVIDERS_FILE at it (default: config/providers.yaml). In Docker, mount it into the container and set the variable in .env:
# docker-compose.override.yml
services:
wit-prod: # wit-dev already mounts the repo, so it sees edits to config/ directly
volumes:
- ./my-providers.yaml:/app/config/my-providers.yaml:roAI_PROVIDERS_FILE=/app/config/my-providers.yamlFallback. A request goes to default. If it fails (error, rate limit, or an empty reply from an openai_compatible provider), Snark tries each provider in fallback in order, skipping the one that already failed and any provider without an API key. For non-streaming requests, a content-filter refusal first retries the same provider once with a softened prompt. Streaming requests skip that retry and move straight to the next provider, and only if nothing has been sent yet; a stream that fails after output has started does not fall back. If every provider fails the request returns an error.
Network. Requests to openai_compatible providers go straight to the configured url: redirects are not followed, and the OPENAI_* variables (including OPENAI_CUSTOM_HEADERS) and the proxy and CA-bundle variables (HTTPS_PROXY, SSL_CERT_FILE) are ignored. A backend behind a private CA or a forward proxy is not supported.
reasoning_effort. Reasoning models (e.g. gpt-oss) spend part of the token budget thinking. Set the effort through extra_body, as in the Groq entry above. Snark does not retry without it: if your model rejects reasoning_effort, remove it from extra_body.
Pricing. Cost estimates use price or free when set, otherwise the vendored LiteLLM price map, searched for the model id, then <provider name>/<model> (for example groq/openai/gpt-oss-20b). A miss shows cost 0 until you set price or free.
- Install Ollama on the host and pull a model:
ollama pull gemma3:27b. - In
config/providers.yaml(or your own file), setdefault: ollama. Theollamaentry is already defined. - On Linux, make Ollama listen on the Docker bridge address (usually
172.17.0.1, seeip addr show docker0) so the container can reach it:OLLAMA_HOST=172.17.0.1 ollama serve. Avoid0.0.0.0: Ollama has no authentication, so that exposes model inference, pull and delete to anything that can reach the machine. If you must use it, firewall port 11434 to the Docker subnet. Docker Desktop on macOS and Windows needs none of this. - Start the stack:
docker compose --profile dev up --build. The compose file already mapshost.docker.internalto the host.
Ollama needs no API key. To use a different model, change model to one you have pulled.
Claude through an OpenAI-compatible endpoint? Anthropic's compatibility layer is meant for testing: it ignores
reasoning_effortandresponse_format, and rejects atemperaturebelow 1 on newer models. Use the nativeclaudetype instead.
# 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 shellMost configuration is via environment variables; AI providers are configured in config/providers.yaml. See .env.example for the full list.
| Variable | Default | Description |
|---|---|---|
AI_PROVIDERS_FILE |
config/providers.yaml |
Path to the AI providers file (default provider, fallback order, models, endpoints). See AI providers |
LENGTH_TOKENS_SHORT / _MEDIUM / _LONG |
60 / 150 / 320 |
Output-token ceiling per ?length= value |
PERSONA_MAX_TOKENS_FLOOR |
0 |
Global floor on every generation's token budget (0=off). Raise to give reasoning models room without editing each persona |
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 |
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) |
LOG_FORMAT |
plain |
Log output: plain (human-readable) or json (one JSON line per record for aggregators) |
LOG_LEVEL |
INFO |
Root log level: DEBUG, INFO, WARNING, ERROR |
GRAFANA_PORT |
3000 |
Host port for Grafana (only used by the observability compose profile) |
GRAFANA_ADMIN_USER / GRAFANA_ADMIN_PASSWORD |
admin / admin |
Grafana admin credentials (only used by the observability compose profile) |
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.
Every reliability event also emits a structured generation_event line at the
single choke point (provider, model, success, fell_back,
content_filtered, streamed, error_code, persona) — the stream that powers
the reliability dashboards below.
A ready-to-run Grafana Loki + Grafana Alloy + Grafana stack ships as an opt-in compose profile that is off by default. Alloy scrapes container stdout into Loki; Grafana comes with a provisioned Loki datasource and a starter "snark Reliability" dashboard.
# start snark + the observability stack together (set LOG_FORMAT=json first)
docker compose --profile dev --profile observability up -d # or --profile prod
# then open Grafana at http://localhost:3000 (default admin / admin)Config lives under observability/ (Loki, Alloy, and Grafana provisioning), so
it is easy to inspect, tweak, or extract. Tune GRAFANA_PORT,
GRAFANA_ADMIN_USER, and GRAFANA_ADMIN_PASSWORD in .env. Retention defaults
to 7 days in Loki.
Backend-neutral by design. snark itself depends on neither Loki nor Grafana — it only emits standard JSON logs on stdout. The bundled stack is one reference example; to use a different backend (ELK, Datadog, CloudWatch, Grafana Cloud, …), skip the profile and point your own collector at snark's stdout.
For rate math (error rate, fallback rate) snark can expose a Prometheus
/metrics endpoint with a snark_generations_total counter
(labels: provider, success, fell_back, content_filtered).
/metrics is served on the same public port as the API, so it is
bearer-token authenticated and OFF by default:
- Set
METRICS_AUTH_TOKENto a long random value to enable it. Empty (the default) means the endpoint returns404— it does not exist. - Callers must send
Authorization: Bearer <token>; a missing or wrong token gets404(the endpoint's existence is not revealed). The comparison is constant-time. - The bundled
observabilityprofile passes the sameMETRICS_AUTH_TOKENthrough to Prometheus automatically, adds a Prometheus datasource, and shows Error rate / Fallback rate panels on the dashboard.
Why a token and not an IP allowlist? On a directly-published port an IP allowlist is unreliable — Docker NAT rewrites external source IPs to the private bridge gateway, and
X-Forwarded-Foris spoofable. Network isolation (firewall / Kubernetes NetworkPolicy so only your scraper can reach the app) remains the recommended primary control; the token is the app-layer guard on top of it.
Under gunicorn (multi-worker) the profile sets PROMETHEUS_MULTIPROC_DIR on a
tmpfs and wires the child_exit hook so scraped values aggregate across
workers. Note: with worker recycling (--max-requests) per-process metric files
accumulate in that dir; being tmpfs-backed it clears on container restart —
raise --max-requests or add a periodic sweep only if it ever grows.
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 charsCost 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. A provider's price or free setting in
config/providers.yaml overrides the map (see AI providers). 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


