Aside
An open-source, self-hosted ad server for AI chat apps. Conversations never leave your server.
An HTML <aside> sits beside the main content. Aside ads work the same way: a card labeled
"Sponsored" next to the model's answer, never inside it.
Status: early (v0.1). The API may change before 1.0.
Try the hosted demo: Rasoi AI, a free cooking assistant, with sponsored cards beside relevant answers.
Why
Ad networks for AI apps (Koah, Gravity, Kontext and others) pick ads by receiving your users' conversation context, and they take a share of the revenue. That works if you need their advertisers. It is a poor fit if you:
- sell sponsorships directly, for example a coding assistant sponsored by dev tools,
- want house ads and cross-promotion between your own products,
- cannot send chats to a third party: health, finance, enterprise, or Indian-language apps with local data expectations.
Aside gives you the ad server and nothing else:
- Privacy. Matching runs in-process on your server with a small local model. Conversation text and its embeddings are never written to disk. See Privacy guarantees.
- No revenue share. You keep what your advertisers pay you.
- Control. You choose advertisers, floors, blocked categories and sensitive topics, and you see every number.
- Labeled, beside, not inside. The model's output is never modified. Ads are separate, labeled cards, which keeps disclosure simple under rules such as India's ASCI code for advertising and similar rules elsewhere. (Not legal advice.)
Prior art in the same spirit: Revive Adserver (self-hosted ad server) and EthicalAds (contextual, privacy-first developer ads).
Install
pip install "aside-ads[embeddings]" # the server; omit [embeddings] for keyword-only matching
pip install aside-ads-client # Python SDK
npm i aside-ads # JS/TypeScript SDK
The packages are available after the first release (v0.1.0). Until then, install from a checkout as below.
Quickstart
From a checkout. Python 3.10+.
git clone https://github.com/Pritom14/aside-ads && cd aside-ads
uv venv -p 3.12 && source .venv/bin/activate
uv pip install -e ".[embeddings]" # or plain `pip install -e ".[embeddings]"`; omit [embeddings] for keyword-only matching
aside seed-demo # demo publisher, API keys (.aside/demo.env) and 8 fictional campaigns
aside serve # http://127.0.0.1:8000 (API docs at /docs, dashboard at /dashboard)
python examples/chat-demo/app.py # second terminal: http://127.0.0.1:8001
aside seed-demo --scenario cooking seeds a different demo instead: "Rasoi AI", a free Indian cooking
assistant, with five fictional kitchen and grocery advertisers bidding in INR (start the server with
ASIDE_CURRENCY=INR). Use a separate ASIDE_DATA_DIR for each scenario.
No database or Redis to set up: SQLite lives in ./.aside/. With the embeddings extra the first start
downloads a ~67 MB ONNX model (BAAI/bge-small-en-v1.5) once; without it, or offline, Aside falls back to
BM25 keyword matching.
Ask for an ad:
source .aside/demo.env
curl -s localhost:8000/v1/serve -H "Authorization: Bearer $ASIDE_API_KEY" -H 'Content-Type: application/json' \
-d '{"context": "My Postgres queries got slow after the table hit 10M rows", "placement": "chat-sidebar", "user_id": "session-42"}'
{
"request_id": "bfe57f78471a4266a51450ea0221ada5",
"ad": {
"label": "Sponsored",
"advertiser": "Slowlog Lens",
"headline": "Find the slow query before your users do",
"body": "Slowlog Lens reads your Postgres query plans and points at the missing index. Free for one database.",
"cta": "Try it free",
"url": "http://127.0.0.1:8000/t/c?t=eyJh...",
"impression_url": "http://127.0.0.1:8000/t/i?t=eyJh...",
"display_url": "127.0.0.1:8001",
"relevance": 0.8237
},
"reason": null
}
The response above is abridged. A sensitive or irrelevant conversation returns "ad": null with "reason": "sensitive_context" or
"below_relevance_threshold".
Other commands:
aside create-key --role publisher --publisher "My App" # creates the publisher and a "default" placement
aside create-key --role admin # log in to /dashboard with this key
aside create-key --role advertiser --advertiser "Acme" # for conversion postbacks
aside migrate # PostgreSQL migrations (serve runs them too)
SDKs
Both clients are MIT licensed, have no runtime dependencies, and return None/null instead of throwing,
so a slow or missing ad server never breaks your chat.
Python (sdks/python, aside-ads-client):
from aside_ads_client import AsideClient
aside = AsideClient("http://127.0.0.1:8000", api_key="aside_pub_...")
ad = aside.get_sponsored(context=messages, user_id=session_id, placement="chat-sidebar")
if ad:
show_beside_answer(ad.label, ad.headline, ad.body, ad.cta, ad.url)
aside.track_impression(ad) # or let the browser fetch ad.impression_url
TypeScript (sdks/js, aside-ads):
import { AsideClient, trackImpression } from "aside-ads";
const aside = new AsideClient({ baseUrl: "http://127.0.0.1:8000", apiKey: process.env.ASIDE_API_KEY });
const ad = await aside.getSponsored({ context: messages, userId: sessionId, placement: "chat-sidebar" });
// ad: { label: "Sponsored", headline, body, cta, url, impressionUrl, ... } | null
// in the browser, once the card is visible:
trackImpression(ad);
React: examples/react/SponsoredCard.tsx renders the card in its own
column and counts the impression at 50% visibility. Keep the publisher key on your server; the browser only
needs the signed url and impression_url.
How it works
POST /v1/serve {context, placement, user_id?, geo?}
-> sensitive guard (no ad on crisis, illness, minors, grief, ...)
-> hard filters (status, placement, schedule, geo, keywords, categories, budget, pacing)
-> relevance (local embeddings or BM25, threshold)
-> frequency cap (per hashed user id)
-> GSP auction (bid x relevance, floor price)
-> signed impression and click URLs
Matching. Each campaign has targeting text, one description per line ("slow postgres queries",
"database indexing"), plus its creatives. With fastembed installed, relevance is the best cosine
similarity between the conversation and any of those lines, using a CPU ONNX model. Otherwise it is BM25
squashed to 0..1. An ad is only eligible above the relevance threshold (default 0.65 for embeddings, 0.5
for BM25, per-campaign override available): no ad beats an irrelevant ad. For Indian-language apps, set
ASIDE_EMBEDDING_MODEL to a multilingual model supported by fastembed and re-tune the threshold.
Sensitive guard. Before any matching, the context is checked against sensitive categories
(self_harm, medical_emergency, serious_illness, minors, abuse_violence, grief, addiction,
financial_distress) using phrases, patterns and, when embeddings are available, example utterances.
Any hit means no ad. Choose categories with ASIDE_SENSITIVE_CATEGORIES and add terms (any language)
with ASIDE_SENSITIVE_TERMS_FILE:
{"self_harm": ["your phrases"], "elections": {"terms": ["ballot"], "exemplars": ["who should I vote for"]}}
Your app can also pass "sensitive": true to force no ad.
Auction. CPC and CPM bids are converted to eCPM (CPC x predicted CTR x 1000, with CTR smoothed from
the campaign's history). Rank = eCPM x relevance. The winner pays a generalized second price: the eCPM it
needed to beat the runner-up, never below the floor (ASIDE_FLOOR_CPM or per placement) and never above its
bid. Campaigns can include or exclude keywords and topic categories, and target countries, regions, a
radius around a point, placements, dates, weekdays and hours.
Budgets and pacing. Daily and lifetime budgets are stored in integer micros. CPM is billed per counted
impression, CPC per counted click. Every charge is one conditional UPDATE on the locked campaign row that
only succeeds if the cost still fits both budgets, so spend cannot exceed a budget however many requests race
(tested with concurrent serves, impressions and clicks on SQLite and PostgreSQL). Even pacing holds a
campaign back when today's spend is ahead of daily_budget x (fraction of day elapsed + buffer). Frequency
caps count serves per hashed user id with an atomic increment-then-check.
Budget reservations. Ads are chosen at serve time but billed later, so without a hold a burst of
concurrent serves could deliver more ads than the budget pays for. When a CPM ad wins, its impression cost
is reserved atomically before the ad is returned. The impression converts the reservation into spend; with
no impression the reservation expires after ASIDE_RESERVATION_TTL seconds (default 120). Budget and pacing
checks count outstanding reservations, so 60 concurrent serves against a 10-impression budget deliver 10
ads, and all 10 are billed. An impression that arrives after its reservation expired is billed if the
budget still has room next to the other outstanding reservations, and recorded but not billed otherwise.
Nothing is billed twice.
CPC needs a different rule: holding a full click price per serve would stop delivery after a handful of
serves, since most ads are never clicked. Each CPC serve instead holds an expected cost, bid x
ASIDE_CPC_EXPECTED_CTR (default 0.05), for ASIDE_CPC_EXPOSURE_TTL seconds (default 900), and the
campaign stops serving while spend plus outstanding holds plus one full click would exceed the budget. The
click converts its hold into the full price. The trade-off: this bounds free clicks but does not eliminate
them. If outstanding ads are clicked at a higher rate than the estimate, or a click arrives after its hold
expired and the budget is gone, the user is still redirected but the click is not billed. A higher expected
CTR or TTL gives fewer free clicks and stops a little earlier near the end of a budget. Set a TTL to 0 to turn
that hold off. Details: src/aside_ads/reservations.py.
Tracking. Impression and click URLs carry HMAC-signed tokens (kind, ids, request id, clearing price,
expiry). Tampered tokens are rejected. Each request id counts at most one impression and one click.
Clicks redirect to the advertiser with aside_click_id=<request id> appended; expired click tokens still
redirect but are not counted. Conversions are posted back by click id with an idempotency key, and
duplicate or concurrent postbacks count once. Basic invalid-traffic filtering drops bot user agents and
applies a per-IP rate limit.
Privacy guarantees
- The
contextyou send is used in memory for one request and discarded. It is not written to the database, to logs, or to any cache. Neither is its embedding or the detected categories. - Stored per event: kind, request id, campaign and creative ids, publisher and placement, cost, a keyed HMAC of the user id (never the raw id), timestamp, and country/region only if you send them.
tests/test_privacy.pysends conversations containing a unique marker through serve, impression, click and conversion, then scans every column of every table and the raw SQLite files for it.- Aside makes no outbound calls with user data. The only outbound request is the one-time model download.
Dashboard
/dashboard, logged in with an admin key: create advertisers, campaigns and creatives, set bids, budgets,
pacing, caps and targeting, manage publishers, placements and API keys, and see impressions, clicks, CTR,
spend and conversions per campaign and day. Server-rendered Jinja2, no build step. The same operations are
available as JSON under /v1/admin (see /docs).
API
| Endpoint | Auth | Purpose |
|---|---|---|
POST /v1/serve |
publisher key | Pick at most one ad for a conversation |
GET or POST /t/i?t=... |
signed token | Impression pixel / beacon |
GET /t/c?t=... |
signed token | Click redirect |
POST /v1/impressions |
publisher key | Server-side impression |
POST /v1/conversions, GET /v1/conversions?click_id=&key= |
advertiser or admin key | Conversion postback |
/v1/admin/* |
admin key | Advertisers, campaigns, creatives, publishers, keys, stats |
Keys go in Authorization: Bearer <key> (or X-API-Key). The GET conversion form exists for
pixel-style postbacks; a key in a query string can end up in access logs, so prefer POST with a header.
PostgreSQL and Redis
SQLite is fine for one process. For more, use PostgreSQL and Redis (shared frequency caps and rate limits):
docker compose up -d --build
docker compose exec aside aside seed-demo --server-url http://localhost:8000
Or set DATABASE_URL=postgresql+asyncpg://... and REDIS_URL=redis://... yourself and install
.[postgres,redis]. aside serve applies Alembic migrations on startup. Set ASIDE_SECRET_KEY to the same
value on every instance. All settings are listed in .env.example.
Aside compared with AI ad networks
| Aside | Contextual AI ad networks | |
|---|---|---|
| Where conversation context goes | Stays on your server | Sent to the network to match ads |
| Advertiser demand | None included: you find and sell to advertisers | The network brings advertisers and fills inventory from day one |
| Revenue share | None | The network keeps a share |
| Control over advertisers and topics | Full | Within the network's settings |
| Ad presentation | Separate labeled card beside the answer | Varies by network |
| Operations | You host it and run your own sales | Add an SDK and an account |
| Cost | Your server | Revenue share |
If you need demand you do not have, a network is the practical choice. Aside is for when you have, or want to build, direct relationships, or cannot send chats to a third party.
Roadmap
- Fill-rate and request reporting per placement.
- Multilingual defaults and per-language thresholds; more sensitive-term packs.
- More creative formats (image cards, product cards) and CTR-based creative rotation.
- Stronger invalid-traffic detection and viewability measurement.
- Advertiser self-serve access and invoicing exports.
Development
uv pip install -e ".[dev,embeddings]"
pytest # no network; BM25 and fake embedders
ASIDE_TEST_FASTEMBED=1 pytest -k fastembed # optional real-model check
ASIDE_TEST_DATABASE_URL=postgresql+asyncpg://aside:aside@127.0.0.1:5432/aside_test pytest # PostgreSQL (wiped)
ruff check . && ruff format --check .
(cd sdks/python && pytest)
(cd sdks/js && npm ci && npx tsc --noEmit)
Layout: src/aside_ads (server), sdks/python, sdks/js, examples/chat-demo, examples/react,
deploy (hosted demo on Fly.io or Hugging Face Spaces).
See CONTRIBUTING.md for the full guide, SECURITY.md to report a vulnerability privately, CHANGELOG.md for releases and RELEASING.md for how they are published.
License
The Aside server is licensed under the GNU Affero General Public License v3.0 only (AGPL-3.0-only, see
LICENSE). The client SDKs in sdks/python and sdks/js are MIT licensed, so commercial AI apps
can embed them without AGPL obligations.
Metadata
Release files for aside-ads 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| aside_ads-0.1.0.tar.gz | 95.7 kB | With provenance | Details for aside_ads-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| aside_ads-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for aside_ads-0.1.0-py3-none-any.whl |
Total release size: 190.6 kB
Release files / aside_ads-0.1.0.tar.gz
| Download URL | aside_ads-0.1.0.tar.gz |
|---|---|
| Size | 95.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1c27883d78344f361bf08177e0b8d342181de23e17ddd0c4b8693d3b9e911348
|
|
BLAKE2b-256 checksum How to use checksums |
8fed4d821f954820fc7b0361489aad761cc261c01eb2bf5f3a222ac4c1915a6a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / aside_ads-0.1.0-py3-none-any.whl
| Download URL | aside_ads-0.1.0-py3-none-any.whl |
|---|---|
| Size | 94.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c62187b896093002735269adf76f361caf412cee288cd847f4d1321a86eed650
|
|
BLAKE2b-256 checksum How to use checksums |
c9a59fa12544e4a9216636de04e7eb062b47592696c6244d4319f7a53bcf1b5d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logSecurity for aside-ads 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for aside_ads-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.