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:

  1. It needs an admin key. Advertiser keys, and OAuth tokens made from them, only get the aside.read scope.
  2. 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.
  3. 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.
  4. 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 with recent_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 and ASIDE_PUBLIC_URL set. 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.

Music in the video: Sascha Ende, ende.app (CC BY 4.0).