[object Object]

← back to Agent Ad Network

initial scaffold: agent ad network MVP — ad server + self-serve API + waitad SDK + admin (TK-10131)

27f2afe48936b1c281ff0a6831702a409283fbd2 · 2026-08-02 00:14:04 -0700 · Steve Abrams

Files touched

Diff

commit 27f2afe48936b1c281ff0a6831702a409283fbd2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Aug 2 00:14:04 2026 -0700

    initial scaffold: agent ad network MVP — ad server + self-serve API + waitad SDK + admin (TK-10131)
---
 .gitignore     |   9 +++
 README.md      |  54 +++++++++++++++++
 admin.html     |  92 +++++++++++++++++++++++++++++
 sdk/waitad.mjs |  26 +++++++++
 sdk/waitad.sh  |  14 +++++
 server.js      | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 375 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8955433
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..30a43de
--- /dev/null
+++ b/README.md
@@ -0,0 +1,54 @@
+# Agent Ad Network
+
+Ads served into **AI-agent wait states** — the loading pauses inside Claude Code,
+autonomous agents, CLIs, and pipelines. Advertisers (humans *or* agents) place ads
+via a self-serve API; any agent fetches one line of sponsored text while its user
+waits on something slow.
+
+**Status: local MVP.** Ledger-only billing (budget debited per impression at
+CPM/1000) — no real payments wired. TK-10131.
+
+## Run
+
+    node server.js          # http://127.0.0.1:9932  (admin: admin / DW2024!)
+
+## The three surfaces
+
+### 1. Supply side — an agent shows an ad during a wait
+    GET /ad?format=text&context=git,deploy     → "[ad] headline — body → click-url"
+    GET /ad?format=json&context=...            → {"ad":{headline,body,clickUrl,sponsor}}
+- Fail-open: 204/null when no eligible ad — integrations never break.
+- `context` keywords boost matching ads (score = cpm × (1 + keyword hits), weighted random).
+- SDK: `sdk/waitad.mjs` (`withAd(promise, {context})` — only shows an ad if the wait
+  exceeds 400ms) and `sdk/waitad.sh` (`waitad "ctx"` / `waitad_wrap`).
+
+### 2. Demand side — agents PLACE ads (self-serve API)
+    POST /api/advertisers  {name, email}                  → {id, apiKey}
+    POST /api/ads          x-api-key: <key>
+      {headline, body, url, keywords[], cpm_usd, budget_usd}
+    GET  /api/ads          x-api-key: <key>               → own ads + impressions/clicks/ctr
+    PATCH /api/ads/:id     x-api-key: <key>  {status: paused|active, budget_usd}
+Because it's a plain JSON API, an AI agent can register and place a campaign
+autonomously — that's the "agents place ads" loop.
+
+### 3. Admin — dashboard + measurement
+    /                → dashboard (basic auth): search/sort/column-toggle table,
+                       expandable rows, created date+time per ad, drill links
+    /admin/events    → raw event ledger (filter ?ad=&type=)
+    /api/stats       → public aggregate counts
+    /c/:adId         → click tracking → 302 to target
+
+## Claude Code integration (paste for Steve — settings.json is Steve-gated)
+
+A PreToolUse hook that prints a sponsored line before long-running Bash commands:
+
+    "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command",
+      "command": "curl -s --max-time 1 'http://127.0.0.1:9932/ad?format=text&context=bash' || true" } ] } ] }
+
+Lighter-touch alternative: put `waitad` output in the statusline command.
+
+## Gated next steps (need Steve's go)
+- Deploy to ads.agentabrams.com (Kamatera + CF DNS) so remote agents can hit it.
+- Real billing: Stripe (TEST first, per AbramsEgo rails).
+- Outreach / advertiser recruitment (send-to-list is gated).
+- Fleet adoption: install the waitad hook into Claude settings (classifier-blocked, Steve-paste).
diff --git a/admin.html b/admin.html
new file mode 100644
index 0000000..482d9d7
--- /dev/null
+++ b/admin.html
@@ -0,0 +1,92 @@
+<!doctype html>
+<html><head><meta charset="utf-8"><title>Agent Ad Network — Admin</title>
+<style>
+  :root { --fg:#e6e6e6; --bg:#101216; --card:#181b21; --line:#2a2f38; --accent:#4fd1c5; }
+  body { font:14px -apple-system,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--fg); margin:24px; }
+  h1 { font-size:18px; letter-spacing:.04em; } h1 small { color:#8a93a3; font-weight:400; }
+  #stats { display:flex; gap:14px; margin:12px 0 18px; flex-wrap:wrap; }
+  .stat { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:10px 16px; }
+  .stat b { display:block; font-size:20px; color:var(--accent); } .stat a { color:inherit; text-decoration:none; }
+  #controls { display:flex; gap:10px; align-items:center; margin-bottom:10px; flex-wrap:wrap; }
+  input[type=search] { background:var(--card); border:1px solid var(--line); color:var(--fg); padding:7px 10px; border-radius:6px; width:280px; }
+  .colchip { border:1px solid var(--line); background:var(--card); color:#8a93a3; border-radius:99px; padding:3px 10px; cursor:pointer; font-size:12px; }
+  .colchip.on { color:var(--accent); border-color:var(--accent); }
+  button { background:var(--card); border:1px solid var(--line); color:var(--fg); border-radius:6px; padding:6px 12px; cursor:pointer; }
+  table { border-collapse:collapse; width:100%; background:var(--card); border:1px solid var(--line); border-radius:8px; overflow:hidden; }
+  th,td { padding:8px 12px; text-align:left; border-bottom:1px solid var(--line); white-space:nowrap; }
+  th { cursor:pointer; color:#8a93a3; font-weight:600; user-select:none; }
+  th.sorted { color:var(--accent); }
+  tr.row:hover { background:#1d212a; cursor:pointer; }
+  tr.detail td { background:#13161c; white-space:normal; color:#aab2c0; }
+  a { color:var(--accent); }
+  .when { color:#8a93a3; font-size:12px; }
+  .pill { border-radius:99px; padding:2px 8px; font-size:11px; }
+  .pill.active { background:#123d2e; color:#5ee6a8; } .pill.paused { background:#3d3512; color:#e6d05e; } .pill.exhausted { background:#3d1212; color:#e65e5e; }
+</style></head><body>
+<h1>AGENT AD NETWORK <small>— ads in AI-agent wait states</small></h1>
+<div id="stats"></div>
+<div id="controls">
+  <input type="search" id="q" placeholder="search all fields (space = AND)">
+  <span id="chips"></span>
+  <button id="reset">Reset</button>
+</div>
+<table><thead id="thead"></thead><tbody id="tbody"></tbody></table>
+<script>
+const ALLCOLS = ['id','advertiser','headline','cpm_usd','budget_usd','spent_usd','impressions','clicks','ctr','status','created_at'];
+const DEFAULT_ON = ['advertiser','headline','spent_usd','impressions','clicks','status','created_at']; // start tight
+const LS = 'aan_admin_v1';
+let state = JSON.parse(localStorage.getItem(LS) || 'null') || { cols: DEFAULT_ON, sort: 'created_at', dir: -1, q: '' };
+let rows = [], open = new Set();
+const save = () => localStorage.setItem(LS, JSON.stringify(state));
+const fmtDate = s => new Date(s).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+
+async function load() {
+  const [ov, st] = await Promise.all([
+    fetch('/admin/api/overview').then(r=>r.json()),
+    fetch('/api/stats').then(r=>r.json())
+  ]);
+  rows = ov;
+  document.getElementById('stats').innerHTML = [
+    ['Advertisers', st.advertisers, '/admin/api/overview'],
+    ['Ads', st.ads, '/admin/api/overview'],
+    ['Impressions', st.impressions, '/admin/events?type=impression'],
+    ['Clicks', st.clicks, '/admin/events?type=click'],
+    ['Booked spend', '$'+st.booked_spend_usd, '/admin/api/overview']
+  ].map(([k,v,href]) => `<div class="stat"><a href="${href}"><b>${v}</b>${k}</a></div>`).join('');
+  render();
+}
+function render() {
+  document.getElementById('q').value = state.q;
+  document.getElementById('chips').innerHTML = ALLCOLS.map(c =>
+    `<span class="colchip ${state.cols.includes(c)?'on':''}" onclick="toggleCol('${c}')">${c.replace('_usd','')}</span>`).join(' ');
+  document.getElementById('thead').innerHTML = '<tr>' + state.cols.map(c =>
+    `<th class="${state.sort===c?'sorted':''}" onclick="sortBy('${c}')">${c.replace('_',' ')} ${state.sort===c?(state.dir>0?'↑':'↓'):''}</th>`).join('') + '</tr>';
+  const terms = state.q.toLowerCase().split(/\s+/).filter(Boolean);
+  let view = rows.filter(r => terms.every(t => JSON.stringify(r).toLowerCase().includes(t)));
+  view.sort((a,b) => (a[state.sort] > b[state.sort] ? 1 : -1) * state.dir);
+  document.getElementById('tbody').innerHTML = view.map(r => {
+    const cell = c => {
+      if (c==='id') return `<a href="/admin/api/ad/${r.id}" onclick="event.stopPropagation()">${r.id}</a>`;
+      if (c==='impressions') return `<a href="/admin/events?ad=${r.id}&type=impression" onclick="event.stopPropagation()">${r.impressions}</a>`;
+      if (c==='clicks') return `<a href="/admin/events?ad=${r.id}&type=click" onclick="event.stopPropagation()">${r.clicks}</a>`;
+      if (c==='status') return `<span class="pill ${r.status}">${r.status}</span>`;
+      if (c==='created_at') return `<span class="when" title="${r.created_at}">🕓 ${fmtDate(r.created_at)}</span>`;
+      if (c==='ctr') return r.ctr + '%';
+      if (c.endsWith('_usd')) return '$' + (+r[c]).toFixed(2);
+      return r[c] ?? '';
+    };
+    const detail = open.has(r.id) ? `<tr class="detail"><td colspan="${state.cols.length}">
+      <b>${r.headline}</b> — ${r.body}<br>
+      target: <a href="${r.url}">${r.url}</a> · keywords: ${(r.keywords||[]).join(', ')||'—'} ·
+      cpm $${r.cpm_usd} · budget $${r.budget_usd} · spent $${r.spent_usd.toFixed(4)} ·
+      <a href="/admin/api/ad/${r.id}">full JSON</a> · <a href="/admin/events?ad=${r.id}">all events</a></td></tr>` : '';
+    return `<tr class="row" onclick="toggleRow('${r.id}')">` + state.cols.map(c=>`<td>${cell(c)}</td>`).join('') + '</tr>' + detail;
+  }).join('') || `<tr><td colspan="${state.cols.length}">no ads match</td></tr>`;
+}
+window.toggleCol = c => { state.cols = state.cols.includes(c) ? state.cols.filter(x=>x!==c) : ALLCOLS.filter(x => state.cols.includes(x) || x===c); save(); render(); };
+window.sortBy = c => { state.dir = state.sort===c ? -state.dir : -1; state.sort = c; save(); render(); };
+window.toggleRow = id => { open.has(id) ? open.delete(id) : open.add(id); render(); };
+document.getElementById('q').addEventListener('input', e => { state.q = e.target.value; save(); render(); });
+document.getElementById('reset').addEventListener('click', () => { state = { cols: DEFAULT_ON, sort:'created_at', dir:-1, q:'' }; save(); render(); });
+load(); setInterval(load, 30000);
+</script></body></html>
diff --git a/sdk/waitad.mjs b/sdk/waitad.mjs
new file mode 100644
index 0000000..d12c75a
--- /dev/null
+++ b/sdk/waitad.mjs
@@ -0,0 +1,26 @@
+// waitad — drop-in SDK: show an ad while an agent waits on something slow.
+// Fail-open by design: if the ad server is down/empty, the wait proceeds silently.
+//   import { withAd, fetchAd } from './waitad.mjs';
+//   const result = await withAd(longRunningPromise, { context: ['deploy', 'shopify'] });
+const BASE = process.env.WAITAD_BASE || 'http://127.0.0.1:9932';
+const MIN_WAIT_MS = 400; // don't flash ads on fast operations
+
+export async function fetchAd(context = []) {
+  try {
+    const ctl = new AbortController();
+    const t = setTimeout(() => ctl.abort(), 800);
+    const r = await fetch(`${BASE}/ad?format=json&context=${encodeURIComponent(context.join(','))}`, { signal: ctl.signal });
+    clearTimeout(t);
+    return (await r.json()).ad || null;
+  } catch { return null; }
+}
+
+export async function withAd(promise, { context = [], stream = process.stderr } = {}) {
+  let shown = false;
+  const timer = setTimeout(async () => {
+    const ad = await fetchAd(context);
+    if (ad) { shown = true; stream.write(`\n  [sponsored while you wait] ${ad.headline} — ${ad.body}\n  → ${ad.clickUrl}\n\n`); }
+  }, MIN_WAIT_MS);
+  try { return await promise; }
+  finally { clearTimeout(timer); if (shown) stream.write(''); }
+}
diff --git a/sdk/waitad.sh b/sdk/waitad.sh
new file mode 100644
index 0000000..b9cccdd
--- /dev/null
+++ b/sdk/waitad.sh
@@ -0,0 +1,14 @@
+# waitad.sh — shell integration for agent wait-state ads. Source this, then:
+#   waitad "git,deploy"          # print one ad (or nothing — fail-open)
+#   waitad_wrap "context" long_command --args   # ad first, then run the command
+WAITAD_BASE="${WAITAD_BASE:-http://127.0.0.1:9932}"
+
+waitad() {
+  curl -s --max-time 1 "$WAITAD_BASE/ad?format=text&context=${1:-}" 2>/dev/null || true
+}
+
+waitad_wrap() {
+  local ctx="$1"; shift
+  waitad "$ctx"
+  "$@"
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..d8abb1a
--- /dev/null
+++ b/server.js
@@ -0,0 +1,180 @@
+#!/usr/bin/env node
+// agent-ad-network — ads served into AI-agent wait states (loading pauses).
+// Zero-dependency Node. Advertisers (human or agent) place ads via API;
+// any agent/CLI fetches GET /ad during a loading wait. Billing is ledger-only
+// (budget debited per impression at CPM/1000) — no real money moves here.
+'use strict';
+const http = require('http');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = Number(process.env.PORT || 9932);
+const HOST = process.env.HOST || '127.0.0.1';
+const ADMIN_USER = process.env.ADMIN_USER || 'admin';
+const ADMIN_PASS = process.env.ADMIN_PASS || 'DW2024!';
+const DATA = path.join(__dirname, 'data');
+const ADVERTISERS_F = path.join(DATA, 'advertisers.json');
+const ADS_F = path.join(DATA, 'ads.json');
+const EVENTS_F = path.join(DATA, 'events.jsonl');
+
+fs.mkdirSync(DATA, { recursive: true });
+const loadJson = (f, fb) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return fb; } };
+let advertisers = loadJson(ADVERTISERS_F, []);
+let ads = loadJson(ADS_F, []);
+const saveAdvertisers = () => fs.writeFileSync(ADVERTISERS_F, JSON.stringify(advertisers, null, 2));
+const saveAds = () => fs.writeFileSync(ADS_F, JSON.stringify(ads, null, 2));
+const logEvent = (ev) => fs.appendFileSync(EVENTS_F, JSON.stringify({ ts: new Date().toISOString(), ...ev }) + '\n');
+const readEvents = () => {
+  try {
+    return fs.readFileSync(EVENTS_F, 'utf8').split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+  } catch { return []; }
+};
+
+const id = (p) => p + '_' + crypto.randomBytes(6).toString('hex');
+const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj, null, 2)); };
+const readBody = (req) => new Promise((resolve, reject) => {
+  let b = ''; req.on('data', c => { b += c; if (b.length > 1e6) req.destroy(); });
+  req.on('end', () => { try { resolve(b ? JSON.parse(b) : {}); } catch (e) { reject(e); } });
+});
+
+function adminAuthed(req, res) {
+  const h = req.headers.authorization || '';
+  const ok = h.startsWith('Basic ') &&
+    Buffer.from(h.slice(6), 'base64').toString() === `${ADMIN_USER}:${ADMIN_PASS}`;
+  if (!ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agent-ad-network"' }); res.end('auth required'); }
+  return ok;
+}
+const advertiserByKey = (req) => advertisers.find(a => a.apiKey === (req.headers['x-api-key'] || ''));
+
+function adStats(adId, events) {
+  const evs = (events || readEvents()).filter(e => e.adId === adId);
+  const impressions = evs.filter(e => e.type === 'impression').length;
+  const clicks = evs.filter(e => e.type === 'click').length;
+  return { impressions, clicks, ctr: impressions ? +(clicks / impressions * 100).toFixed(2) : 0 };
+}
+
+// ---- ad selection: eligible = active + budget remaining; score = cpm * (1 + keyword hits)
+function pickAd(contextWords) {
+  const eligible = ads.filter(a => a.status === 'active' && a.spent_usd < a.budget_usd);
+  if (!eligible.length) return null;
+  const scored = eligible.map(a => {
+    const hits = (a.keywords || []).filter(k => contextWords.includes(k.toLowerCase())).length;
+    return { a, w: Math.max(a.cpm_usd, 0.01) * (1 + hits) };
+  });
+  let r = Math.random() * scored.reduce((s, x) => s + x.w, 0);
+  for (const x of scored) { r -= x.w; if (r <= 0) return x.a; }
+  return scored[scored.length - 1].a;
+}
+
+const server = http.createServer(async (req, res) => {
+  const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+  const p = u.pathname;
+  try {
+    // ---------- public: serve an ad into a wait state ----------
+    if (req.method === 'GET' && p === '/ad') {
+      const context = (u.searchParams.get('context') || '').toLowerCase().split(/[,\s]+/).filter(Boolean);
+      const ad = pickAd(context);
+      if (!ad) { // fail-open: agents must never break on an empty network
+        if ((u.searchParams.get('format') || 'text') === 'json') return json(res, 200, { ad: null });
+        res.writeHead(204); return res.end();
+      }
+      ad.spent_usd = +(ad.spent_usd + ad.cpm_usd / 1000).toFixed(6);
+      if (ad.spent_usd >= ad.budget_usd) ad.status = 'exhausted';
+      saveAds();
+      logEvent({ type: 'impression', adId: ad.id, advertiserId: ad.advertiserId, context });
+      const clickUrl = `http://${req.headers.host}/c/${ad.id}`;
+      if ((u.searchParams.get('format') || 'text') === 'json') {
+        return json(res, 200, { ad: { id: ad.id, headline: ad.headline, body: ad.body, clickUrl, sponsor: ad.sponsor } });
+      }
+      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
+      return res.end(`[ad] ${ad.headline} — ${ad.body}  →  ${clickUrl}\n`);
+    }
+
+    // ---------- public: click-through ----------
+    if (req.method === 'GET' && p.startsWith('/c/')) {
+      const ad = ads.find(a => a.id === p.slice(3));
+      if (!ad) return json(res, 404, { error: 'unknown ad' });
+      logEvent({ type: 'click', adId: ad.id, advertiserId: ad.advertiserId });
+      res.writeHead(302, { Location: ad.url }); return res.end();
+    }
+
+    // ---------- advertiser self-serve (agents place ads here) ----------
+    if (req.method === 'POST' && p === '/api/advertisers') {
+      const b = await readBody(req);
+      if (!b.name) return json(res, 400, { error: 'name required' });
+      const adv = { id: id('adv'), apiKey: id('key'), name: String(b.name), email: b.email || null, created_at: new Date().toISOString() };
+      advertisers.push(adv); saveAdvertisers();
+      return json(res, 201, adv);
+    }
+    if (p === '/api/ads' && req.method === 'POST') {
+      const adv = advertiserByKey(req);
+      if (!adv) return json(res, 401, { error: 'valid x-api-key required (POST /api/advertisers to register)' });
+      const b = await readBody(req);
+      for (const f of ['headline', 'body', 'url']) if (!b[f]) return json(res, 400, { error: `${f} required` });
+      const ad = {
+        id: id('ad'), advertiserId: adv.id, sponsor: adv.name,
+        headline: String(b.headline).slice(0, 90), body: String(b.body).slice(0, 200), url: String(b.url),
+        keywords: Array.isArray(b.keywords) ? b.keywords.map(k => String(k).toLowerCase()).slice(0, 20) : [],
+        cpm_usd: Math.max(Number(b.cpm_usd) || 1, 0.01), budget_usd: Math.max(Number(b.budget_usd) || 10, 0.1),
+        spent_usd: 0, status: 'active', created_at: new Date().toISOString()
+      };
+      ads.push(ad); saveAds();
+      return json(res, 201, ad);
+    }
+    if (p === '/api/ads' && req.method === 'GET') {
+      const adv = advertiserByKey(req);
+      if (!adv) return json(res, 401, { error: 'valid x-api-key required' });
+      const events = readEvents();
+      return json(res, 200, ads.filter(a => a.advertiserId === adv.id).map(a => ({ ...a, ...adStats(a.id, events) })));
+    }
+    if (p.startsWith('/api/ads/') && req.method === 'PATCH') {
+      const adv = advertiserByKey(req);
+      if (!adv) return json(res, 401, { error: 'valid x-api-key required' });
+      const ad = ads.find(a => a.id === p.slice('/api/ads/'.length) && a.advertiserId === adv.id);
+      if (!ad) return json(res, 404, { error: 'ad not found' });
+      const b = await readBody(req);
+      if (b.status && ['active', 'paused'].includes(b.status)) ad.status = b.status;
+      if (b.budget_usd) ad.budget_usd = Math.max(Number(b.budget_usd), ad.spent_usd);
+      saveAds(); return json(res, 200, ad);
+    }
+    if (req.method === 'GET' && p === '/api/stats') {
+      const events = readEvents();
+      return json(res, 200, {
+        advertisers: advertisers.length, ads: ads.length,
+        impressions: events.filter(e => e.type === 'impression').length,
+        clicks: events.filter(e => e.type === 'click').length,
+        booked_spend_usd: +ads.reduce((s, a) => s + a.spent_usd, 0).toFixed(4)
+      });
+    }
+    if (req.method === 'GET' && p === '/healthz') return json(res, 200, { ok: true, ads: ads.length });
+
+    // ---------- admin (basic auth) ----------
+    if (p === '/' || p.startsWith('/admin')) {
+      if (!adminAuthed(req, res)) return;
+      if (p === '/admin/api/overview') {
+        const events = readEvents();
+        return json(res, 200, ads.map(a => ({ ...a, advertiser: (advertisers.find(v => v.id === a.advertiserId) || {}).name, ...adStats(a.id, events) })));
+      }
+      if (p.startsWith('/admin/api/ad/')) {
+        const ad = ads.find(a => a.id === p.slice('/admin/api/ad/'.length));
+        if (!ad) return json(res, 404, { error: 'not found' });
+        return json(res, 200, { ...ad, ...adStats(ad.id) });
+      }
+      if (p === '/admin/events') {
+        const evs = readEvents().filter(e =>
+          (!u.searchParams.get('ad') || e.adId === u.searchParams.get('ad')) &&
+          (!u.searchParams.get('type') || e.type === u.searchParams.get('type')));
+        return json(res, 200, evs.slice(-500));
+      }
+      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+      return res.end(fs.readFileSync(path.join(__dirname, 'admin.html')));
+    }
+
+    json(res, 404, { error: 'not found' });
+  } catch (e) {
+    json(res, 500, { error: e.message });
+  }
+});
+
+server.listen(PORT, HOST, () => console.log(`agent-ad-network listening on http://${HOST}:${PORT}`));

(oldest)  ·  back to Agent Ad Network  ·  accept dbrown second admin credential (standing rule) d3d3416 →