Stateful social engagement toolkit for AI agents. Built because we shipped broken tooling and decided to fix it properly.
npm install @percival-labs/agent-socialWe built engagement tools for Clawstr (Nostr) and Moltbook from scratch. They had fundamental problems:
| Problem | Impact | Fix |
|---|---|---|
| Timestamps showed time without date | Couldn't tell today from last week | ISO 8601 everywhere |
| Every scan returned full history | Reported old events as new | Cursor-based scanning |
| No dedup against what we'd already seen | Wasted time on stale data | State store with seen event tracking |
| Moltbook verification checked wrong path | Threaded replies stayed "pending" forever | Handle both response shapes |
| Challenge solver couldn't parse split words | "for ty" (forty) failed verification | Fragment merging parser |
| Duplicate posts from retry logic | Same comment posted 4 times | Content-hash idempotent publishing |
| Dead relays stayed in rotation | Timeouts on every scan | Relay health tracking with cooldown |
We're the team building Vouch — trust infrastructure for AI agents. If we can't trust our own tooling, that's a problem. So we fixed it and open-sourced the result.
import { NostrAdapter, StateStore, statefulScan, idempotentPublish } from '@percival-labs/agent-social';
const store = new StateStore('.agent-social/state.json');
const nostr = new NostrAdapter({
relays: [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
],
nsec: process.env.VOUCH_NSEC!,
}, store);
await nostr.connect();
// Scan for new replies — only events since last check
const result = await statefulScan({ adapter: nostr, stateStore: store });
console.log(`${result.meta.new} new events (${result.meta.total} total on relays)`);
for (const event of result.events) {
// Every event has a full ISO 8601 timestamp — never just "8:48 AM"
console.log(`[${event.timestamp}] ${event.author.slice(0, 8)}: ${event.content.slice(0, 100)}`);
}
// Publish — idempotent, won't double-post
const pub = await idempotentPublish(
{ adapter: nostr, stateStore: store },
{ content: 'Trust is earned, not given.', channel: 'vouch' },
);
if (pub.deduplicated) {
console.log('Already posted this — skipped');
} else {
console.log(`Posted: ${pub.id}`);
}
await nostr.disconnect();import { MoltbookAdapter, StateStore, statefulScan, idempotentPublish } from '@percival-labs/agent-social';
const store = new StateStore('.agent-social/state.json');
const moltbook = new MoltbookAdapter({
apiKey: process.env.MOLTBOOK_API_KEY!,
proxyUrl: 'http://localhost:9111', // Optional: route through sanitizing proxy
}, store);
// Scan for new activity
const result = await statefulScan({ adapter: moltbook, stateStore: store });
// Publish a reply — handles verification challenges automatically
// If challenge has split words like "for ty" (forty), the parser handles it
// If the comment is already created, it verifies without re-posting (no duplicates)
const pub = await idempotentPublish(
{ adapter: moltbook, stateStore: store },
{
content: 'Interesting point about cold-start trust scoring.',
replyTo: 'post-id-123',
channel: 'ai-agents',
},
);Every scan uses a cursor to only fetch events since the last check. Events are tracked by ID — if you've seen it before, it won't appear in results.
// First scan: gets everything, sets cursor
const first = await statefulScan({ adapter, stateStore: store });
// first.meta.total = 150, first.meta.new = 150
// Second scan: only new events since cursor
const second = await statefulScan({ adapter, stateStore: store });
// second.meta.total = 3, second.meta.new = 3Content is SHA-256 hashed before publishing. If the same content was already posted, the publish is skipped.
const pub1 = await idempotentPublish(config, { content: 'Hello world' });
// pub1.deduplicated = false (posted)
const pub2 = await idempotentPublish(config, { content: 'Hello world' });
// pub2.deduplicated = true (skipped — same content hash)
const pub3 = await idempotentPublish(config, { content: ' HELLO WORLD ' });
// pub3.deduplicated = true (normalization: trim + lowercase + collapse spaces)Nostr relays are tracked for success/failure. After 3 consecutive failures, a relay enters 30-minute cooldown and is skipped.
// relay.nostr.band times out 3 times → automatically skipped
// Other relays continue working
// After 30 minutes, it's retried
// One success resets the failure counterMoltbook returns verification challenges in two different shapes. This toolkit handles both:
// Shape A (top-level): { verification_code: "abc", challenge_text: "..." }
// Shape B (nested): { comment: { verification: { verification_code: "abc", challenge_text: "..." } } }
// The adapter handles both automatically — you never need to think about itThe challenge solver handles obfuscated word problems:
import { parseChallenge } from '@percival-labs/agent-social';
// Split words
parseChallenge('A bot has for ty points. It gains six teen. Total?');
// → { numbers: [40, 16], operation: 'add', answer: 56 }
// Randomized case
parseChallenge('A node has fOr Ty connections.');
// → { numbers: [40], ... }
// Bracket decorators
parseChallenge('Score is [f]o[r] t{y} points.');
// → { numbers: [40], ... }# Show engagement state
agent-social status
# Show relay health
agent-social health
# Import legacy engagement log
agent-social migrate ./engagement-log.jsonAll state is stored in a single JSON file (default: .agent-social/state.json):
- Cursors — per-platform "last checked at" timestamps
- Seen event IDs — dedup cache (auto-pruned at 10K entries)
- Published content hashes — prevents duplicate posts
- Relay health — success/failure counts, cooldown timestamps
- Engagement log — append-only record of all actions
The state file is human-readable. You can inspect it, back it up, or reset it by deleting it.
| Export | Description |
|---|---|
StateStore |
File-backed engagement state |
statefulScan() |
Cursor-based scan with dedup |
idempotentPublish() |
Content-hash dedup publishing |
contentHash() |
SHA-256 of normalized content |
fromUnixSeconds() |
Unix epoch → ISO 8601 |
toUnixSeconds() |
ISO 8601 → Unix epoch |
now() |
Current time as ISO 8601 |
formatAge() |
"3h ago (2026-03-08T12:00:00Z)" |
| Export | Description |
|---|---|
NostrAdapter |
PlatformAdapter for Nostr relays |
RelayPool |
Connection-reusing relay pool with health tracking |
| Export | Description |
|---|---|
MoltbookAdapter |
PlatformAdapter for Moltbook API |
parseChallenge() |
Robust word problem solver |
extractChallenge() |
Extract verification from any response shape |
isVerified() |
Check if response indicates verification complete |
sanitize() |
Strip prompt injection patterns |
sanitizeDeep() |
Recursive sanitization for objects |
-
Timestamps without dates are useless.
toLocaleTimeString()outputs "8:48 AM" — which day? We reported week-old events as breaking news. -
State is not optional. Without cursors and dedup, every scan returns the full history. Your agent re-processes everything, wastes tokens, and creates duplicate responses.
-
Test the exact failure modes. Our Moltbook verification worked for top-level posts but silently failed for threaded replies because the response shape was different. We didn't have a test for the nested shape.
-
Shallow copies mutate shared state.
{ ...EMPTY_STATE }looks like a fresh copy but shares inner objects. One instance modifyingcursorscorrupted every other instance. Classic JavaScript footgun. -
Dead relays waste everyone's time.
relay.nostr.bandtimed out consistently but stayed in rotation. Every scan waited 10 seconds for nothing. Track health, skip failures, retry later.
MIT — Percival Labs
Built because we believe broken tooling shouldn't be a secret. If your agent engagement tools have similar problems, use this or steal the patterns. That's why it's open source.