Skip to content

Commit ead69f1

Browse files
committed
feat: a live agent reacting on the landing page
The judge feedback's top ask was to show the agentic thesis running, not just described. This wires the yield-router example to the production matcher and surfaces it on the site. - examples/agentic-yield-router/live-agent.cjs: a long-running service that is a real Sluice subscriber. The matcher pushes every matched DemoDex swap (the same events in /feed) to its webhook; it verifies the HMAC, reasons over the swap reusing agent.js's decision logic verbatim, and appends the verdict to a ring buffer. Binds loopback; exposed via Caddy at /agent/*; runs as the sluice-live-agent systemd unit. Normalises a contract Swap event into the deposit shape the policy expects; everything else is the audited example code. - Landing: a new 'An agent, reacting live' section polls /api/agent-log.json and renders the agent's decisions (decision, amount, swap, signature-verified badge, reason, on-chain tx link) with DOM nodes, never innerHTML. Stacks on mobile. Verified live end to end: a real 700k swap was matched, pushed, signature-verified, and decided REBALANCE, visible on the page with its tx. This turns 'an agent could react to Sluice' into 'an agent is reacting, now.'
1 parent 9df8745 commit ead69f1

4 files changed

Lines changed: 254 additions & 2 deletions

File tree

examples/agentic-yield-router/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ Which path it uses depends on reachability. Set `PUBLIC_WEBHOOK_URL` to a public
8484

8585
> The sandbox has to be able to reach your `/webhook`. On a laptop, expose it first (`ngrok http 8791` or a Cloudflare Tunnel) and set `PUBLIC_WEBHOOK_URL=https://<your-tunnel>/webhook` before running `demo.sh`.
8686
87+
## Running live against the hosted site
88+
89+
`live-agent.cjs` is this same agent wired to the production matcher. It is a
90+
real Sluice subscriber: the matcher pushes every matched DemoDex swap to its
91+
`/webhook`, it verifies the HMAC, reasons over the swap (heuristic, or Claude if
92+
`ANTHROPIC_API_KEY` is set), and appends its decision to a ring buffer the
93+
landing page reads.
94+
95+
```bash
96+
SLUICE_WEBHOOK_SECRET=<shared with the matcher> \
97+
AGENT_DECISION_LOG=/var/www/sluice/api/agent-log.json \
98+
node live-agent.cjs # binds 127.0.0.1:8795
99+
```
100+
101+
On sluice.unitynodes.com it runs as the `sluice-live-agent` systemd unit; Caddy
102+
proxies `/agent/*` to it, an injected subscription delivers DemoDex swaps to
103+
`/agent/webhook`, and the "An agent, reacting live" section of the landing polls
104+
`/api/agent-log.json`. It reuses `agent.js`'s decision logic verbatim; the only
105+
addition is normalising a contract `Swap` event into the deposit shape the
106+
policy reasons over.
107+
87108
## How it works
88109

89110
1. **Sluice matches** a Transfer event against your subscription's predicate (e.g. `amount >= 50,000 CSPR to a watched pool`) and POSTs a JSON webhook:
@@ -144,6 +165,8 @@ Point your MCP client at the Casper MCP Server and CSPR.trade MCP, drop the thre
144165

145166
## Files
146167

168+
- `live-agent.cjs` — long-running service that subscribes to the live matcher and logs decisions for the site (see "Running live" above).
169+
147170
| File | What it is |
148171
| ------------- | ------------------------------------------------------------------------- |
149172
| `agent.js` | Webhook server: HMAC verify → `decideRebalance` → log/act. Exports the policy for unit testing. |
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env node
2+
// Live yield-router agent for the Sluice site.
3+
//
4+
// This is agent.js wired to the LIVE matcher: Sluice delivers each matched
5+
// DemoDex swap (the same events you see in /feed) to this service's webhook, it
6+
// verifies the HMAC, reasons over the swap, and appends its decision to a small
7+
// ring buffer the landing page polls. It turns "an agent could react to Sluice
8+
// events" into "an agent is reacting, right now, on the page."
9+
//
10+
// It reuses agent.js's audited decision logic verbatim (require, no fork). The
11+
// only new thing here is normalising a DemoDex `Swap` event into the deposit
12+
// shape decideRebalance expects, and persisting decisions for the UI.
13+
//
14+
// Env:
15+
// PORT listen port (default 8795, bind 127.0.0.1)
16+
// SLUICE_WEBHOOK_SECRET shared HMAC secret (same as the matcher)
17+
// AGENT_DECISION_LOG path to write the ring JSON (served by the site)
18+
// AGENT_LOG_MAX ring size (default 25)
19+
// LARGE_DEPOSIT_CSPR rebalance threshold, forwarded to agent.js (default 50000)
20+
// ANTHROPIC_API_KEY if set, agent.js reasons with Claude; else heuristic
21+
22+
const express = require('express');
23+
const fs = require('node:fs');
24+
const path = require('node:path');
25+
const { decideRebalance, verifyHmacSignature } = require('./agent.js');
26+
27+
const PORT = Number(process.env.PORT || 8795);
28+
const HOST = process.env.HOST || '127.0.0.1';
29+
const SECRET = process.env.SLUICE_WEBHOOK_SECRET || '';
30+
const LOG_PATH = process.env.AGENT_DECISION_LOG || '/tmp/sluice-agent-log.json';
31+
const LOG_MAX = Number(process.env.AGENT_LOG_MAX || 25);
32+
const MOTES_PER_CSPR = 1_000_000_000n;
33+
34+
// In-memory ring, seeded from disk so a restart keeps recent history.
35+
let ring = [];
36+
try {
37+
const prev = JSON.parse(fs.readFileSync(LOG_PATH, 'utf8'));
38+
if (Array.isArray(prev.decisions)) ring = prev.decisions.slice(0, LOG_MAX);
39+
} catch { /* first run, no log yet */ }
40+
41+
function persist() {
42+
const body = JSON.stringify({ updated_at: new Date().toISOString(), agent: 'yield-router', decisions: ring }, null, 2);
43+
const tmp = `${LOG_PATH}.tmp`;
44+
try {
45+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
46+
fs.writeFileSync(tmp, body);
47+
fs.renameSync(tmp, LOG_PATH);
48+
} catch (e) {
49+
console.error('[live-agent] could not persist log:', e.message);
50+
}
51+
}
52+
53+
// Normalise whatever Sluice delivered into the { amount, to_account_hash, ... }
54+
// shape agent.js reasons over. A native Transfer already has `amount`; a DemoDex
55+
// contract `Swap` carries it under data.amount_in, so map that across and label
56+
// the pool by the token being swapped into.
57+
function normalise(payload) {
58+
const e = payload.event || payload;
59+
if (!e || typeof e !== 'object') return null;
60+
if (e.amount) return e; // native transfer, already the right shape
61+
if (e.name === 'Swap' && e.data && e.data.amount_in) {
62+
return {
63+
amount: String(e.data.amount_in),
64+
to_account_hash: `demodex-${String(e.data.token_out || 'pool').toLowerCase()}`,
65+
token_in: e.data.token_in,
66+
token_out: e.data.token_out,
67+
deploy_hash: e.deploy_hash,
68+
block_height: e.block_height,
69+
timestamp: e.timestamp,
70+
contract_package_hash: e.contract_package_hash,
71+
};
72+
}
73+
return null;
74+
}
75+
76+
const app = express();
77+
78+
app.post('/webhook', express.raw({ type: '*/*' }), async (req, res) => {
79+
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
80+
const signature = req.get('X-Sluice-Signature');
81+
const idempotencyKey = req.get('X-Sluice-Idempotency-Key');
82+
83+
let verified = false;
84+
if (SECRET) {
85+
verified = verifyHmacSignature(raw, signature, SECRET);
86+
if (!verified) { res.status(401).json({ error: 'invalid X-Sluice-Signature' }); return; }
87+
}
88+
89+
let payload;
90+
try { payload = JSON.parse(raw.toString('utf8')); }
91+
catch { res.status(400).json({ error: 'body is not valid JSON' }); return; }
92+
93+
res.sendStatus(200); // ack first, think after
94+
95+
const event = normalise(payload);
96+
if (!event) { console.warn('[live-agent] delivery had no usable amount, skipped'); return; }
97+
98+
try {
99+
const decision = await decideRebalance(event);
100+
const row = {
101+
at: new Date().toISOString(),
102+
verified,
103+
subscription_id: payload.subscription_id ?? null,
104+
deploy_hash: event.deploy_hash ?? null,
105+
block_height: event.block_height ?? null,
106+
swap: event.token_in && event.token_out ? `${event.token_in}${event.token_out}` : null,
107+
amount_cspr: decision.amountCspr,
108+
decision: decision.action,
109+
decided_by: decision.decidedBy || 'heuristic',
110+
reason: decision.reason,
111+
plan: decision.plan ?? null,
112+
explorer: event.deploy_hash ? `https://testnet.cspr.live/transaction/${event.deploy_hash}` : null,
113+
};
114+
ring.unshift(row);
115+
if (ring.length > LOG_MAX) ring.length = LOG_MAX;
116+
persist();
117+
console.log(`[live-agent] ${row.decision} (${row.decided_by}) on ${row.amount_cspr} CSPR swap`);
118+
} catch (e) {
119+
console.error('[live-agent] decision loop failed:', e.message);
120+
}
121+
});
122+
123+
app.get('/decisions', (_req, res) => res.json({ updated_at: new Date().toISOString(), decisions: ring }));
124+
app.get('/health', (_req, res) => res.json({ ok: true, decisions: ring.length, has_secret: !!SECRET }));
125+
126+
if (!SECRET) console.warn('[live-agent] WARNING: SLUICE_WEBHOOK_SECRET unset, signature verification DISABLED. Set it in production.');
127+
app.listen(PORT, HOST, () => console.log(`live yield-router agent on ${HOST}:${PORT}, log=${LOG_PATH}, threshold=${process.env.LARGE_DEPOSIT_CSPR || 50000} CSPR`));

web/index.html

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
*/
5454
@media (max-width: 880px) {
5555
nav > div { padding-left: 16px !important; padding-right: 16px !important; gap: 12px !important; flex-wrap: wrap; height: auto !important; min-height: 60px; padding-top: 10px; padding-bottom: 10px; }
56+
.agent-grid { grid-template-columns: minmax(0,1fr) !important; gap: 28px !important; }
5657
nav > div > div[style*="display:flex;gap:2px"] { order: 99; flex-basis: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 4px; gap: 4px; }
5758
nav a[href="/app"] { padding: 8px 14px !important; font-size: 13px !important; }
5859
/* one-column stacks for hero, two-audience, code, transparency, workspace, mcp-demo, v0.1 limits, final CTA */
@@ -590,7 +591,37 @@ <h2 style="margin:18px 0 0;font:500 clamp(40px,5.4vw,72px)/1.02 'Casper Sans',In
590591
</div>
591592
</section>
592593

593-
594+
<!-- ============ LIVE AGENT ============ -->
595+
<section id="live-agent" data-reveal style="background:#000;color:#fff;border-bottom:1px solid #000">
596+
<div style="max-width:1320px;margin:0 auto;padding:120px 32px 96px">
597+
<div style="font:500 11px 'JetBrains Mono';letter-spacing:.24em;text-transform:uppercase;color:#bcfc07;margin-bottom:24px;display:flex;align-items:center;gap:12px">
598+
<span id="agent-pulse" style="width:9px;height:9px;background:#3edc64;border-radius:50%;box-shadow:0 0 0 0 rgba(62,220,100,.6);animation:pulse-acc 2s infinite"></span>
599+
04 &nbsp;·&nbsp;An agent, reacting live
600+
</div>
601+
<div class="agent-grid" style="display:grid;grid-template-columns:minmax(0,.85fr) minmax(0,1.15fr);gap:56px;align-items:start">
602+
<div>
603+
<h2 style="margin:0;font:500 clamp(34px,4.4vw,56px)/1.03 'Casper Sans',Inter;letter-spacing:-.03em;color:#fff;max-width:460px">Not a mock. A real agent on the other end of the feed.</h2>
604+
<p style="margin:22px 0 0;font:400 16.5px/1.65 'Casper Sans',Inter;color:#999;max-width:460px">The autonomous yield-router from <code style="font-family:'JetBrains Mono';background:rgba(188,252,7,.15);color:#bcfc07;padding:1px 6px">examples/agentic-yield-router</code> is a live Sluice subscriber. Every DemoDex swap you see in the feed is also pushed to it. It verifies the HMAC signature, reasons over the swap, and decides whether to rebalance, with zero human in the loop. What you see on the right is its real decision log, updating as swaps land.</p>
605+
<div style="margin-top:34px;display:flex;flex-direction:column;gap:14px;font:400 14.5px/1.5 'Casper Sans',Inter;color:#ccc">
606+
<div style="display:flex;align-items:flex-start;gap:14px"><span style="width:18px;height:18px;background:#bcfc07;color:#000;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;font:500 11px 'JetBrains Mono'">1</span> Sluice pushes the matched swap to the agent's webhook.</div>
607+
<div style="display:flex;align-items:flex-start;gap:14px"><span style="width:18px;height:18px;background:#bcfc07;color:#000;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;font:500 11px 'JetBrains Mono'">2</span> It rejects anything without a valid signature (401), then decides.</div>
608+
<div style="display:flex;align-items:flex-start;gap:14px"><span style="width:18px;height:18px;background:#bcfc07;color:#000;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;font:500 11px 'JetBrains Mono'">3</span> Dry-run: it logs the rebalance plan instead of moving funds.</div>
609+
</div>
610+
<div style="margin-top:32px;font:400 12.5px/1.6 'JetBrains Mono';color:#666;letter-spacing:.02em">Live decisions: <code style="background:#1a1a1a;padding:1px 6px;color:#bcfc07">GET /api/agent-log.json</code></div>
611+
</div>
612+
<div style="background:#0d0d0d;border:1px solid #1a1a1a;box-shadow:8px 8px 0 #bcfc07;min-width:0">
613+
<div style="display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid #1a1a1a">
614+
<span style="font:500 11px 'JetBrains Mono';color:#bcfc07;letter-spacing:.06em">YIELD-ROUTER · DECISION LOG</span>
615+
<div style="flex:1"></div>
616+
<span id="agent-status" style="font:500 10px 'JetBrains Mono';color:#666;letter-spacing:.1em">CONNECTING…</span>
617+
</div>
618+
<div id="agent-live" style="max-height:440px;overflow-y:auto">
619+
<div style="padding:28px 20px;color:#666;font:400 13px 'Casper Sans',Inter;text-align:center">Waiting for the next swap…</div>
620+
</div>
621+
</div>
622+
</div>
623+
</div>
624+
</section>
594625

595626
<!-- ============ RECIPES ============ -->
596627
<section id="recipes" data-reveal style="background:#f4f4f4;border-bottom:1px solid #000">
@@ -1175,6 +1206,6 @@ <h2 id="tour-h" style="margin:10px 0 0;font:500 clamp(26px,3vw,36px)/1.05 'Caspe
11751206
</div>
11761207
</div>
11771208

1178-
<script src="/landing.js?v=31"></script>
1209+
<script src="/landing.js?v=33"></script>
11791210
</body>
11801211
</html>

web/landing.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,77 @@ sluice subscribe --predicate ./predicate.json --webhook https://your.app/hook --
996996
}
997997
tick();
998998
setInterval(tick, POLL_MS);
999+
1000+
// Live agent decision log. Built with DOM nodes (never innerHTML) so the
1001+
// server-sourced fields cannot inject markup.
1002+
const agentRoot = $('agent-live');
1003+
const agentStatus = $('agent-status');
1004+
function agentRow(d) {
1005+
const wrap = document.createElement('div');
1006+
wrap.setAttribute('style', 'padding:16px 20px;border-bottom:1px solid #1a1a1a');
1007+
const top = document.createElement('div');
1008+
top.setAttribute('style', 'display:flex;align-items:center;gap:10px;flex-wrap:wrap');
1009+
const isRebal = d.decision === 'REBALANCE';
1010+
const badge = document.createElement('span');
1011+
badge.setAttribute('style', `font:500 10px 'JetBrains Mono';letter-spacing:.06em;padding:3px 8px;color:#000;background:${isRebal ? '#bcfc07' : '#3edc64'}`);
1012+
badge.textContent = String(d.decision || '—');
1013+
top.appendChild(badge);
1014+
const amt = document.createElement('span');
1015+
amt.setAttribute('style', "font:500 13px 'JetBrains Mono';color:#fff");
1016+
amt.textContent = `${Number(d.amount_cspr || 0).toLocaleString('en-US')} CSPR`;
1017+
top.appendChild(amt);
1018+
if (d.swap) {
1019+
const sw = document.createElement('span');
1020+
sw.setAttribute('style', "font:400 12px 'JetBrains Mono';color:#888");
1021+
sw.textContent = String(d.swap);
1022+
top.appendChild(sw);
1023+
}
1024+
const vb = document.createElement('span');
1025+
vb.setAttribute('style', `font:500 10px 'JetBrains Mono';letter-spacing:.06em;margin-left:auto;color:${d.verified ? '#3edc64' : '#ff8a65'}`);
1026+
vb.textContent = d.verified ? 'SIG ✓' : 'UNVERIFIED';
1027+
top.appendChild(vb);
1028+
wrap.appendChild(top);
1029+
const reason = document.createElement('div');
1030+
reason.setAttribute('style', "margin-top:8px;font:400 13px/1.5 'Casper Sans',Inter;color:#bbb");
1031+
reason.textContent = String(d.reason || '');
1032+
wrap.appendChild(reason);
1033+
const meta = document.createElement('div');
1034+
meta.setAttribute('style', "margin-top:8px;display:flex;gap:12px;flex-wrap:wrap;font:400 11px 'JetBrains Mono';color:#666");
1035+
const by = document.createElement('span');
1036+
by.textContent = `decided by ${String(d.decided_by || 'heuristic')}`;
1037+
meta.appendChild(by);
1038+
const h = safeHash(d.deploy_hash);
1039+
if (h) {
1040+
const a = document.createElement('a');
1041+
a.href = `https://testnet.cspr.live/transaction/${h}`;
1042+
a.target = '_blank'; a.rel = 'noopener';
1043+
a.setAttribute('style', 'color:#4589f6;text-decoration:none');
1044+
a.textContent = `tx ${h.slice(0, 8)}…`;
1045+
meta.appendChild(a);
1046+
}
1047+
wrap.appendChild(meta);
1048+
return wrap;
1049+
}
1050+
async function pollAgent() {
1051+
if (!agentRoot) return;
1052+
try {
1053+
const r = await fetch('/api/agent-log.json?t=' + Date.now(), { cache: 'no-store' });
1054+
if (!r.ok) throw new Error('HTTP ' + r.status);
1055+
const j = await r.json();
1056+
const rows = Array.isArray(j.decisions) ? j.decisions : [];
1057+
if (agentStatus) agentStatus.textContent = rows.length ? `${rows.length} DECISIONS` : 'IDLE';
1058+
if (rows.length === 0) return;
1059+
agentRoot.textContent = '';
1060+
rows.slice(0, 12).forEach((d) => agentRoot.appendChild(agentRow(d)));
1061+
} catch {
1062+
if (agentStatus) agentStatus.textContent = 'OFFLINE';
1063+
}
1064+
}
1065+
// reuse the landing hash validator if present, else a local one
1066+
function safeHash(x) { const s = String(x || ''); return /^[0-9a-f]{6,64}$/i.test(s) ? s : ''; }
1067+
pollAgent();
1068+
setInterval(pollAgent, 5000);
1069+
9991070
// Live Casper testnet head, every 5s via /api/chain/head (cached server-side at 3s).
10001071
const blockH = $('hero-block-h');
10011072
const blockDot = $('hero-block-dot');

0 commit comments

Comments
 (0)