@percival-labs/agent-social
v0.1.0
Published
Stateful social engagement toolkit for AI agents — Nostr + Moltbook with dedup, cursor tracking, and relay health
Maintainers
Readme
@percival-labs/agent-social
Stateful social engagement toolkit for AI agents. Built because we shipped broken tooling and decided to fix it properly.
npm install @percival-labs/agent-socialWhy This Exists
We 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.
Quick Start
Nostr (Clawstr)
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();Moltbook
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',
},
);Core Concepts
Stateful Scanning
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 = 3Idempotent Publishing
Content 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)Relay Health
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 Verification
Moltbook 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], ... }CLI
# Show engagement state
agent-social status
# Show relay health
agent-social health
# Import legacy engagement log
agent-social migrate ./engagement-log.jsonState File
All 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.
API Reference
Core
| 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)" |
Nostr
| Export | Description |
|--------|-------------|
| NostrAdapter | PlatformAdapter for Nostr relays |
| RelayPool | Connection-reusing relay pool with health tracking |
Moltbook
| 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 |
What We Learned
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.
License
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.
