Last week I launched Aside, an open-source ad server for AI chat apps that you run on your own server, so the conversation never goes to an ad network.
There is a cost to that I skipped over in the launch post. If you run your own ad server, you are also the ad ops team. Someone has to notice that a sponsor's budget ran out at lunchtime, work out why an ad stopped showing, and reply when the sponsor emails "how is my campaign doing?". Ad networks have people and dashboards for this. A two-person AI app does not.
What a two-person AI app usually does have is an AI agent open all day. So Aside 0.2.0 has an MCP server built in, and the agent does that work from Aside's numbers.
What it looks like
The video is a real Claude Code session against a demo database with a simulated day of traffic for the cooking app from the launch post (Rasoi AI, five sponsors, amounts in rupees).
You: How did my ads do yesterday? What should I fix? Agent: Lohasutra's daily budget ran out at 13:34. After that, 1,318 more conversations matched it but got no ad from it ... You: Lohasutra said yes. Raise their daily budget to ₹300. Agent: (Claude Code asks you to approve the tool call) Done: ₹90 -> ₹300, logged. The server exposes six tools:
| Tool | Who can use it | What it does |
|---|---|---|
ads_overview | admin, advertiser | One day: totals, spend against budget, when a budget ran out, matches lost to budget, fill rate per placement, and a "needs attention" list in plain words |
campaign_report | admin, advertiser | Settings and day-by-day results for one campaign |
delivery_report | admin | Fill rate per placement and why requests got no ad |
advertiser_report | admin, advertiser | A Markdown report you can send to a sponsor as is |
recent_changes | admin | The audit log: who changed what, before and after |
update_campaign_settings | admin | Pause or resume a campaign, change its daily or total budget, or its bid |
Counting the ads you didn't show
"The budget ran out at 13:34" was easy. Aside already records every billed impression and click, so the report walks them in order and finds the one that crossed the budget.
"1,318 more conversations matched it after that" was not possible in 0.1.0, because of the order things happened in the serving path. A campaign with no budget left was filtered out before relevance scoring. If it was the only campaign that fit the question, the response said below_relevance_threshold. That was wrong: the ad was relevant, it just had no money left. Worse, nobody could see how much demand the sponsor was missing.
In 0.2.0, campaigns that are out of budget still get scored, and the ones that would have qualified are recorded (simplified from serving.py):
relevant = [sc for sc in scored if sc.relevance >= threshold] funded_matches = [sc for sc in relevant if funded(sc.campaign)] missed.extend(sc.campaign.id for sc in relevant if not funded(sc.campaign)) if funded_matches: return None, funded_matches return ("budget_exhausted" if missed else "below_relevance_threshold"), [] After the response is built, one upsert adds 1 to a counter for that campaign and day. Fill rate works the same way: a counter per day, placement and outcome (filled, sensitive_context, budget_exhausted, and so on). If counting fails, the error is logged and the ad is still served.
That counter is the most useful number for the sponsor conversation. "Your budget ran out" is a complaint. "1,318 more cookware questions matched your ad after your budget ran out" is a reason to raise it.
The agent only sees counts
Aside never stored conversation text, and there is a test that fails if any reaches the database. The new tables follow the same rule. They hold a day, a placement, an outcome and a number.
So there is nothing private for the agent to leak, and that is what makes advertiser access safe to offer. An advertiser key gets a read-only connection scoped to that advertiser's own campaigns. A sponsor can ask their own agent "how did my campaign do this week?" without you writing a report or giving them dashboard access.
Letting an agent change spend
Only one tool writes, update_campaign_settings, and it has four guards:
- It needs an admin key. Advertiser keys, and OAuth tokens made from them, only get the
aside.readscope. - It is marked as changing data. The tool carries
destructiveHint: true, and the server's instructions tell the agent to confirm the exact change with you first. - The client asks you. Claude Code asks for approval before each call by default. That is the prompt in the video. I recorded with the default permission mode on purpose.
- Every change is logged with the values before and after, the key that made it, and
via: agent. The agent can read the log back withrecent_changes.
Connecting
Clients that can send headers (Claude Code, Cursor, scripts) pass an Aside key:
claude mcp add --transport http aside https://ads.example.com/mcp \ --header "Authorization: Bearer $ASIDE_ADMIN_KEY" Clients that use OAuth (custom connectors in claude.ai and Claude Desktop, ChatGPT) add https://ads.example.com/mcp as a connector. The client registers itself, and you land on Aside's /connect page, where you paste an admin or advertiser key to approve. The OAuth server is built on the MCP Python SDK's auth support. Codes and tokens are random and stored only as SHA-256 hashes. Codes work once, refresh tokens rotate, and every token is tied to the key you pasted, so revoking that key in the dashboard ends the connection.
Try it without real traffic
pip install "aside-ads[embeddings]" git clone https://github.com/Pritom14/aside-ads && cd aside-ads python examples/agent-demo/simulate.py --data-dir .aside-agent-demo export ASIDE_DATA_DIR=.aside-agent-demo ASIDE_TIMEZONE=Asia/Kolkata ASIDE_CURRENCY=INR ASIDE_FLOOR_CPM=20 aside serve --port 8010 Then, in another terminal:
source .aside-agent-demo/demo.env claude mcp add --transport http aside http://127.0.0.1:8010/mcp \ --header "Authorization: Bearer $ASIDE_ADMIN_KEY" Ask "How did my ads do yesterday? What should I fix?" and see what it finds.
What isn't done yet
- The traffic in the demo is simulated. The agent session is real. The chats it reports on are not.
- OAuth with the web clients is untested live. The test suite runs the whole OAuth flow end to end: register, approve, exchange, refresh, revoke. I have not yet connected claude.ai or ChatGPT to a public deployment. Web clients can't reach
localhost, so you need a public HTTPS URL andASIDE_PUBLIC_URLset. If you try it, I would like to hear what breaks. - Aside still does not bring advertisers. It is for apps that sell sponsorships directly or run house ads.
What's next
Personal agents that send a morning brief or work in the background are becoming something people read every day. That is a new place for an ad, and a risky one. The next placements on the roadmap are:
-
digest: one labeled sponsored line in a brief or a finished-task report, matched on your server. -
shortlist: one labeled option under the agent's own pick, never acted on unless the user chooses it. - A reference OpenClaw plugin that uses both.
For sponsors, the next tools are drafting a campaign for review and alerts when a budget is about to run out.
If you run an AI app with sponsors, what would you want to ask your agent about your ads? That is the list I am building from.
- Repo: github.com/Pritom14/aside-ads (server AGPL-3.0, SDKs MIT)
- Demo: aside-ads.onrender.com
-
pip install aside-ads·npm i aside-ads
Music in the video: Sascha Ende, ende.app (CC BY 4.0).