← 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
A .gitignoreA README.mdA admin.htmlA sdk/waitad.mjsA sdk/waitad.shA server.js
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 →