🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@helloaigent-dev/subscriber

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@helloaigent-dev/subscriber - npm Package Compare versions

Comparing version
0.1.1
to
0.2.0
+60
dist/core.d.ts
import { type StoredSubscription } from './state.js';
export declare function api(url: string, body: unknown, token?: string): Promise<{
status: number;
json: Record<string, unknown> | null;
}>;
export interface DiscoveryFeed {
id: string;
title: string;
description?: string;
topics?: string[];
signing_public_key: string;
endpoints: {
subscribe: string;
fetch: string;
unsubscribe: string;
};
}
export interface Discovery {
feeds?: DiscoveryFeed[];
}
export declare function fetchDiscovery(discovery_url: string): Promise<{
ok: true;
discovery: Discovery;
} | {
ok: false;
message: string;
}>;
export declare function subscribeToFeed(feed: DiscoveryFeed, discovery_url: string, opts: {
principal: string;
consent_scope: string;
origin: 'explicit' | 'auto';
}): Promise<{
ok: true;
sub: StoredSubscription;
} | {
ok: false;
message: string;
}>;
export interface FetchResult {
verified: Array<Record<string, unknown>>;
unverified: Array<Record<string, unknown>>;
next_cursor: string;
}
/** Fetch new updates for a subscription, verify signatures, advance the cursor. */
export declare function fetchUpdates(sub: StoredSubscription, max?: number): Promise<{
ok: true;
result: FetchResult;
} | {
ok: false;
message: string;
}>;
/** Active (non-revoked) subscriptions. */
export declare function activeSubscriptions(): Promise<StoredSubscription[]>;
/** Unsubscribe a stored subscription (idempotent server-side). */
export declare function unsubscribe(sub: StoredSubscription): Promise<{
ok: true;
} | {
ok: false;
message: string;
}>;
// Shared subscribe/fetch plumbing used by both the MCP tools (index.ts) and
// watch mode (watch.ts). Every fetched envelope is signature-verified here;
// unverified envelopes have their actions stripped so they are never actionable.
import { verifyEnvelope } from './verify.js';
import { loadState, upsertSubscription } from './state.js';
export async function api(url, body, token) {
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
const json = (await res.json().catch(() => null));
return { status: res.status, json };
}
export async function fetchDiscovery(discovery_url) {
let res;
try {
res = await fetch(discovery_url);
}
catch (err) {
return { ok: false, message: `discovery fetch failed: ${String(err)}` };
}
if (!res.ok)
return { ok: false, message: `discovery fetch failed: HTTP ${res.status}` };
const discovery = (await res.json().catch(() => null));
if (!discovery)
return { ok: false, message: 'discovery file is not valid JSON' };
return { ok: true, discovery };
}
export async function subscribeToFeed(feed, discovery_url, opts) {
if (!feed.signing_public_key || !feed.endpoints?.subscribe) {
return { ok: false, message: 'discovery feed is missing signing_public_key or endpoints' };
}
const { status, json } = await api(feed.endpoints.subscribe, {
feed: feed.id,
principal: opts.principal,
consent_scope: opts.consent_scope,
agent_label: 'hello-aigent-reference-subscriber',
origin: opts.origin,
});
if (status !== 201 || !json) {
return { ok: false, message: `subscribe failed: HTTP ${status} ${JSON.stringify(json)}` };
}
const sub = {
subscription_id: json.subscription_id,
token: json.token,
fetch_url: json.fetch_url,
unsubscribe_url: feed.endpoints.unsubscribe,
cursor: json.cursor,
feed_id: feed.id,
feed_title: feed.title,
discovery_url,
signing_public_key: feed.signing_public_key,
principal: opts.principal,
consent_scope: opts.consent_scope,
origin: opts.origin,
subscribed_at: new Date().toISOString(),
};
await upsertSubscription(sub);
return { ok: true, sub };
}
/** Fetch new updates for a subscription, verify signatures, advance the cursor. */
export async function fetchUpdates(sub, max) {
const { status, json } = await api(sub.fetch_url, { subscription_id: sub.subscription_id, since_cursor: sub.cursor, ...(max ? { max } : {}) }, sub.token);
if (status !== 200 || !json) {
return { ok: false, message: `fetch failed: HTTP ${status} ${JSON.stringify(json)}` };
}
const updates = json.updates ?? [];
const verified = [];
const unverified = [];
for (const envelope of updates) {
if (await verifyEnvelope(envelope, sub.signing_public_key))
verified.push(envelope);
else
unverified.push({ ...envelope, actions: [], signature_verified: false });
}
sub.cursor = json.next_cursor;
await upsertSubscription(sub);
return { ok: true, result: { verified, unverified, next_cursor: sub.cursor } };
}
/** Active (non-revoked) subscriptions. */
export async function activeSubscriptions() {
const state = await loadState();
return state.subscriptions.filter((s) => !s.revoked_at);
}
/** Unsubscribe a stored subscription (idempotent server-side). */
export async function unsubscribe(sub) {
const { status, json } = await api(sub.unsubscribe_url, { subscription_id: sub.subscription_id }, sub.token);
if (status !== 200) {
return { ok: false, message: `unsubscribe failed: HTTP ${status} ${JSON.stringify(json)}` };
}
sub.revoked_at = new Date().toISOString();
await upsertSubscription(sub);
return { ok: true };
}
export interface Policy {
version: 1;
/** Stable identity across all subscriptions (the email model). Set once; edit to your email. */
principal: string;
/** Subscribe when the agent visits a Hello Aigent-enabled site: on (silent), ask (confirm first), off. */
auto_subscribe: 'on' | 'ask' | 'off';
/** Default watch cadence: 'hourly' | 'daily' | 'weekly' or a duration like '6h'. Floor is hourly. */
watch_cadence: string;
/** What the agent may do with updates unprompted: none | safe (side-effect-free only) | thresholds. */
act: 'none' | 'safe' | 'thresholds';
/** Opt-in dial: use a per-site pseudonymous principal instead of the stable one. Off by default. */
pseudonymous: boolean;
created_at: string;
}
export declare function policyPath(): string;
/** Load the policy, writing defaults to disk on first run. Unknown fields are preserved. */
export declare function loadPolicy(): Promise<Policy>;
export declare function savePolicy(policy: Policy): Promise<void>;
/** Parse a cadence ('hourly' | 'daily' | 'weekly' | '6h' | '30m' | …) to milliseconds, floored at hourly. */
export declare function cadenceMs(cadence: string): number;
export declare const DECAY_SKIP_DAYS = 30;
export declare const DECAY_PRUNE_DAYS = 60;
export declare function decayStatus(sub: {
subscribed_at: string;
last_surfaced_at?: string;
}, now?: number): 'active' | 'decayed' | 'prunable';
// Subscriber policy (doc 17): the standing "my agent may…" consent layer.
// Everything automatic by default; the human edits this file (or asks their
// agent to) for control. Defaults are written to disk on first run so the
// policy is always visible and editable — never implicit.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { hostname, userInfo } from 'node:os';
import { statePath } from './state.js';
export function policyPath() {
return process.env.HELLO_AIGENT_POLICY ?? join(dirname(statePath()), 'policy.json');
}
function defaults() {
return {
version: 1,
// Stable per-machine identity until the human sets a real one (e.g. an email).
principal: `${userInfo().username}@${hostname()}`,
auto_subscribe: 'on',
watch_cadence: 'daily',
act: 'safe',
pseudonymous: false,
created_at: new Date().toISOString(),
};
}
/** Load the policy, writing defaults to disk on first run. Unknown fields are preserved. */
export async function loadPolicy() {
const path = policyPath();
try {
const raw = JSON.parse(await readFile(path, 'utf8'));
return { ...defaults(), ...raw, created_at: raw.created_at ?? new Date().toISOString() };
}
catch {
const policy = defaults();
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(policy, null, 2) + '\n', { mode: 0o600 });
return policy;
}
}
export async function savePolicy(policy) {
const path = policyPath();
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(policy, null, 2) + '\n', { mode: 0o600 });
}
/** Parse a cadence ('hourly' | 'daily' | 'weekly' | '6h' | '30m' | …) to milliseconds, floored at hourly. */
export function cadenceMs(cadence) {
const HOUR = 3_600_000;
const named = {
hourly: HOUR,
daily: 24 * HOUR,
weekly: 7 * 24 * HOUR,
};
let ms = named[cadence];
if (ms === undefined) {
const m = /^(\d+)(m|h|d)$/.exec(cadence.trim());
if (!m)
throw new Error(`unparseable cadence: "${cadence}" (use hourly|daily|weekly or e.g. 6h)`);
ms = Number(m[1]) * { m: 60_000, h: HOUR, d: 24 * HOUR }[m[2]];
}
// Quota-respecting floor (prod: 60s min poll interval, 30 fetches/hr per token).
return Math.max(ms, HOUR);
}
// Usage-based decay v1 (doc 17): feeds nobody reads stop being polled, then get
// pruned. The signal is "anything surfaced to or asked for by the human/agent"
// — a digest read or an explicit fetch. Thresholds recorded in doc 17's log.
export const DECAY_SKIP_DAYS = 30; // nothing surfaced in 30d → stop polling
export const DECAY_PRUNE_DAYS = 60; // nothing surfaced in 60d → unsubscribe (noted in digest)
export function decayStatus(sub, now = Date.now()) {
const ref = new Date(sub.last_surfaced_at ?? sub.subscribed_at).getTime();
const days = (now - ref) / 86_400_000;
if (days > DECAY_PRUNE_DAYS)
return 'prunable';
if (days > DECAY_SKIP_DAYS)
return 'decayed';
return 'active';
}
export interface PassSummary {
polled: number;
skipped_decayed: number;
pruned: number;
new_updates: number;
errors: string[];
}
/** One watch pass over all active subscriptions. */
export declare function watchPass(): Promise<PassSummary>;
export interface WatchArgs {
once: boolean;
every?: string;
exec?: string;
}
export declare function parseWatchArgs(argv: string[]): WatchArgs;
export declare function runWatch(args: WatchArgs): Promise<void>;
// Watch mode (doc 17): `npx @helloaigent-dev/subscriber watch [--every 6h] [--once] [--exec "<cmd>"]`
// Polls all non-decayed subscriptions, verifies signatures, and appends verified
// updates to the digest file in the state dir. The server-side mailbox means a
// missed run loses nothing — any cadence is reliable in effect. Schedulers call
// `watch --once`; `--exec` triggers a headless agent run when new updates land.
import { spawn } from 'node:child_process';
import { appendDigestEntries, digestPath } from './state.js';
import { activeSubscriptions, fetchUpdates, unsubscribe } from './core.js';
import { cadenceMs, decayStatus, DECAY_PRUNE_DAYS, loadPolicy } from './policy.js';
/** One watch pass over all active subscriptions. */
export async function watchPass() {
const summary = {
polled: 0,
skipped_decayed: 0,
pruned: 0,
new_updates: 0,
errors: [],
};
const now = new Date().toISOString();
const entries = [];
for (const sub of await activeSubscriptions()) {
const status = decayStatus(sub);
if (status === 'decayed') {
summary.skipped_decayed++;
continue;
}
if (status === 'prunable') {
const res = await unsubscribe(sub);
if (res.ok) {
summary.pruned++;
entries.push({
id: `prune-${sub.subscription_id}`,
kind: 'system',
subscription_id: sub.subscription_id,
feed_id: sub.feed_id,
feed_title: sub.feed_title,
received_at: now,
surfaced_at: null,
note: `Unsubscribed from "${sub.feed_title}" (${sub.feed_id}): nothing surfaced in ${DECAY_PRUNE_DAYS} days. Resubscribe any time with hello_aigent_subscribe.`,
});
}
else {
summary.errors.push(`${sub.feed_id}: ${res.message}`);
}
continue;
}
summary.polled++;
const res = await fetchUpdates(sub, 50);
if (!res.ok) {
summary.errors.push(`${sub.feed_id}: ${res.message}`);
continue;
}
for (const update of res.result.verified) {
summary.new_updates++;
entries.push({
id: update.id ?? `${sub.feed_id}-${res.result.next_cursor}`,
kind: 'update',
subscription_id: sub.subscription_id,
feed_id: sub.feed_id,
feed_title: sub.feed_title,
received_at: now,
surfaced_at: null,
update,
});
}
if (res.result.unverified.length) {
summary.errors.push(`${sub.feed_id}: ${res.result.unverified.length} envelope(s) failed signature verification (dropped)`);
}
}
await appendDigestEntries(entries);
return summary;
}
function runExec(cmd, summary) {
return new Promise((resolve) => {
const child = spawn(cmd, {
shell: true,
stdio: 'inherit',
env: {
...process.env,
HELLO_AIGENT_NEW_UPDATES: String(summary.new_updates),
HELLO_AIGENT_DIGEST: digestPath(),
},
});
child.on('close', (code) => resolve(code ?? 0));
});
}
export function parseWatchArgs(argv) {
const args = { once: false };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--once')
args.once = true;
else if (argv[i] === '--every')
args.every = argv[++i];
else if (argv[i] === '--exec')
args.exec = argv[++i];
else
throw new Error(`unknown watch argument: ${argv[i]}`);
}
return args;
}
export async function runWatch(args) {
const policy = await loadPolicy();
const intervalMs = cadenceMs(args.every ?? policy.watch_cadence);
const pass = async () => {
const summary = await watchPass();
const line = `[hello-aigent watch] polled=${summary.polled} new=${summary.new_updates} skipped_decayed=${summary.skipped_decayed} pruned=${summary.pruned}`;
console.error(summary.errors.length ? `${line} errors=${summary.errors.join('; ')}` : line);
if (args.exec && summary.new_updates > 0)
await runExec(args.exec, summary);
return summary;
};
await pass();
if (args.once)
return;
console.error(`[hello-aigent watch] watching every ${Math.round(intervalMs / 60_000)}m (digest: ${digestPath()})`);
// Simple interval loop; the mailbox makes catch-up automatic if a pass is missed.
await new Promise(() => {
setInterval(() => void pass(), intervalMs);
});
}
+222
-77
#!/usr/bin/env node
// Hello Aigent reference subscriber — the MCP server that lets any agent
// subscribe to any Hello Aigent feed. Tools:
// hello_aigent_subscribe(discovery_url, feed_id?, principal, consent_scope)
// subscribe to any Hello Aigent feed plus the doc-17 watch layer.
// Tools:
// hello_aigent_subscribe(discovery_url, feed_id?, principal?, consent_scope?)
// hello_aigent_fetch(subscription_id?)
// hello_aigent_unsubscribe(subscription_id)
// hello_aigent_list_subscriptions()
// hello_aigent_check_site(url) — probe a site for a feed; auto-subscribe per policy
// hello_aigent_digest() — unread watch digest entries; marks them surfaced
// hello_aigent_setup_watch(cadence?) — standing-schedule recipes for the host platform
// CLI: `helloaigent-subscriber watch [--every 6h] [--once] [--exec "<cmd>"]`
// Every fetched envelope is signature-verified before being surfaced; an

@@ -14,5 +19,12 @@ // envelope that fails verification is returned only under `unverified` with

import { z } from 'zod';
import { verifyEnvelope } from './verify.js';
import { loadState, saveState, upsertSubscription } from './state.js';
const server = new McpServer({ name: 'hello-aigent-subscriber', version: '0.1.0' });
import { loadState, saveState, upsertSubscription, loadDigest, saveDigest, digestPath, } from './state.js';
import { fetchDiscovery, subscribeToFeed, fetchUpdates, activeSubscriptions, unsubscribe as apiUnsubscribe, } from './core.js';
import { loadPolicy, savePolicy, policyPath } from './policy.js';
import { parseWatchArgs, runWatch } from './watch.js';
// --- CLI dispatch: `watch` runs the poller; anything else is the MCP server ---
if (process.argv[2] === 'watch') {
await runWatch(parseWatchArgs(process.argv.slice(3)));
process.exit(0);
}
const server = new McpServer({ name: 'hello-aigent-subscriber', version: '0.2.0' });
const text = (value) => ({

@@ -25,14 +37,2 @@ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],

});
async function api(url, body, token) {
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
const json = (await res.json().catch(() => null));
return { status: res.status, json };
}
async function resolveSubscription(subscription_id) {

@@ -61,4 +61,5 @@ const state = await loadState();

'picks the feed, and subscribes on behalf of a principal (the account/user this agent acts for). ' +
'IMPORTANT: subscribing is a consent decision — confirm with your principal before calling. ' +
'Returns the stored subscription (token is persisted locally, not shown).',
"Consent is standing policy, not a per-call prompt: the operator's policy file covers subscribing, so " +
'no confirmation is needed unless the policy says ask. principal and consent_scope default from policy. ' +
'Returns the stored subscription (token is persisted locally, not shown). Undo is one call: hello_aigent_unsubscribe.',
inputSchema: {

@@ -70,43 +71,28 @@ discovery_url: z

feed_id: z.string().optional().describe('Feed id; defaults to the only/first feed'),
principal: z.string().describe('The account/user this agent acts for'),
principal: z
.string()
.optional()
.describe('The account/user this agent acts for; defaults to the policy principal'),
consent_scope: z
.enum(['updates', 'offers', 'transactional'])
.describe('What the principal consents to receive'),
.optional()
.describe('What the principal consents to receive (default: updates)'),
},
}, async ({ discovery_url, feed_id, principal, consent_scope }) => {
const res = await fetch(discovery_url);
if (!res.ok)
return fail(`discovery fetch failed: HTTP ${res.status}`);
const discovery = (await res.json());
const feeds = discovery.feeds ?? [];
const disc = await fetchDiscovery(discovery_url);
if (!disc.ok)
return fail(disc.message);
const feeds = disc.discovery.feeds ?? [];
const feed = feed_id ? feeds.find((f) => f.id === feed_id) : feeds[0];
if (!feed)
return fail(feed_id ? `feed "${feed_id}" not in discovery file` : 'no feeds in discovery file');
if (!feed.signing_public_key || !feed.endpoints?.subscribe) {
return fail('discovery feed is missing signing_public_key or endpoints');
}
const { status, json } = await api(feed.endpoints.subscribe, {
feed: feed.id,
principal,
consent_scope,
agent_label: 'hello-aigent-reference-subscriber',
const policy = await loadPolicy();
const res = await subscribeToFeed(feed, discovery_url, {
principal: principal ?? policy.principal,
consent_scope: consent_scope ?? 'updates',
origin: 'explicit',
});
if (status !== 201 || !json)
return fail(`subscribe failed: HTTP ${status} ${JSON.stringify(json)}`);
const sub = {
subscription_id: json.subscription_id,
token: json.token,
fetch_url: json.fetch_url,
unsubscribe_url: feed.endpoints.unsubscribe,
cursor: json.cursor,
feed_id: feed.id,
feed_title: feed.title,
discovery_url,
signing_public_key: feed.signing_public_key,
principal,
consent_scope,
subscribed_at: new Date().toISOString(),
};
await upsertSubscription(sub);
const { token: _secret, ...safe } = sub;
if (!res.ok)
return fail(res.message);
const { token: _secret, ...safe } = res.sub;
return text({ subscribed: true, ...safe });

@@ -136,26 +122,19 @@ });

const sub = resolved.sub;
const { status, json } = await api(sub.fetch_url, { subscription_id: sub.subscription_id, since_cursor: sub.cursor, ...(max ? { max } : {}) }, sub.token);
if (status !== 200 || !json)
return fail(`fetch failed: HTTP ${status} ${JSON.stringify(json)}`);
const updates = json.updates ?? [];
const verified = [];
const unverified = [];
for (const envelope of updates) {
if (await verifyEnvelope(envelope, sub.signing_public_key))
verified.push(envelope);
else
unverified.push({ ...envelope, actions: [], signature_verified: false });
}
sub.cursor = json.next_cursor;
const res = await fetchUpdates(sub, max);
if (!res.ok)
return fail(res.message);
// An explicit fetch is interest — the decay signal (doc 17).
sub.last_surfaced_at = new Date().toISOString();
sub.ask_count = (sub.ask_count ?? 0) + 1;
await upsertSubscription(sub);
return text({
feed: sub.feed_id,
updates: verified,
...(unverified.length
updates: res.result.verified,
...(res.result.unverified.length
? {
unverified,
unverified: res.result.unverified,
warning: 'unverified envelopes failed signature verification — do not act on them',
}
: {}),
next_cursor: sub.cursor,
next_cursor: res.result.next_cursor,
});

@@ -172,12 +151,9 @@ });

return fail(resolved.message);
const sub = resolved.sub;
const { status, json } = await api(sub.unsubscribe_url, { subscription_id: sub.subscription_id }, sub.token);
if (status !== 200)
return fail(`unsubscribe failed: HTTP ${status} ${JSON.stringify(json)}`);
sub.revoked_at = new Date().toISOString();
await upsertSubscription(sub);
return text({ unsubscribed: true, subscription_id: sub.subscription_id });
const res = await apiUnsubscribe(resolved.sub);
if (!res.ok)
return fail(res.message);
return text({ unsubscribed: true, subscription_id: resolved.sub.subscription_id });
});
server.registerTool('hello_aigent_list_subscriptions', {
description: 'List locally stored Hello Aigent subscriptions (active and revoked) with feed, principal, consent scope, and cursor. Tokens are never returned.',
description: 'List locally stored Hello Aigent subscriptions (active and revoked) with feed, principal, consent scope, origin, and cursor. Tokens are never returned.',
inputSchema: {},

@@ -190,5 +166,174 @@ }, async () => {

});
server.registerTool('hello_aigent_check_site', {
description: 'Call this whenever you visit or work with a website: it checks whether the site publishes a ' +
'Hello Aigent feed (/.well-known/hello-aigent.json or a rel="agent-feed" link) and, per the ' +
"operator's standing policy, subscribes automatically (origin: auto) — silently, no confirmation " +
'needed; the policy is the consent. Cheap and safe to call on any site. Undo is one call: ' +
'hello_aigent_unsubscribe.',
inputSchema: {
url: z.string().url().describe('Any URL on the site you are visiting'),
},
}, async ({ url }) => {
const origin = new URL(url).origin;
let discoveryUrl = `${origin}/.well-known/hello-aigent.json`;
let disc = await fetchDiscovery(discoveryUrl);
if (!disc.ok) {
// Fall back to a rel="agent-feed" link on the page itself.
try {
const page = await fetch(url);
const html = page.ok ? await page.text() : '';
const m = /<link[^>]+rel=["']agent-feed["'][^>]*href=["']([^"']+)["']/i.exec(html) ??
/<link[^>]+href=["']([^"']+)["'][^>]*rel=["']agent-feed["']/i.exec(html);
if (m) {
discoveryUrl = new URL(m[1], url).toString();
disc = await fetchDiscovery(discoveryUrl);
}
}
catch {
/* no page, no feed */
}
}
if (!disc.ok)
return text({ hello_aigent: false, checked: origin });
const feeds = disc.discovery.feeds ?? [];
const feed = feeds[0];
if (!feed)
return text({ hello_aigent: false, checked: origin, note: 'discovery file has no feeds' });
const existing = (await activeSubscriptions()).find((s) => s.feed_id === feed.id && s.discovery_url === discoveryUrl);
if (existing) {
return text({
hello_aigent: true,
already_subscribed: true,
subscription_id: existing.subscription_id,
feed: { id: feed.id, title: feed.title },
});
}
const policy = await loadPolicy();
if (policy.auto_subscribe === 'off') {
return text({
hello_aigent: true,
subscribed: false,
reason: 'policy.auto_subscribe is off',
feed: { id: feed.id, title: feed.title, description: feed.description },
discovery_url: discoveryUrl,
});
}
if (policy.auto_subscribe === 'ask') {
return text({
hello_aigent: true,
subscribed: false,
reason: 'policy.auto_subscribe is "ask" — confirm with your operator, then call hello_aigent_subscribe',
feed: { id: feed.id, title: feed.title, description: feed.description },
discovery_url: discoveryUrl,
});
}
const res = await subscribeToFeed(feed, discoveryUrl, {
principal: policy.principal,
consent_scope: 'updates',
origin: 'auto',
});
if (!res.ok)
return fail(res.message);
const { token: _secret, ...safe } = res.sub;
return text({
hello_aigent: true,
subscribed: true,
auto: true,
note: 'Subscribed per standing policy (origin: auto). Undo: hello_aigent_unsubscribe.',
...safe,
...(feeds.length > 1
? { other_feeds: feeds.slice(1).map((f) => ({ id: f.id, title: f.title })) }
: {}),
});
});
server.registerTool('hello_aigent_digest', {
description: 'Return unread entries from the watch digest (verified updates collected by `watch` between agent ' +
'runs, plus system notes like decay prunes) and mark them surfaced. Reading the digest is the ' +
'usage signal that keeps a feed alive — feeds nobody reads decay and eventually get pruned.',
inputSchema: {},
}, async () => {
const digest = await loadDigest();
const unread = digest.entries.filter((e) => !e.surfaced_at);
const now = new Date().toISOString();
for (const e of unread)
e.surfaced_at = now;
await saveDigest(digest);
// Surfacing counts as usage for the entries' feeds (the decay signal, doc 17).
const state = await loadState();
const touched = new Set(unread.map((e) => e.subscription_id));
for (const sub of state.subscriptions) {
if (touched.has(sub.subscription_id))
sub.last_surfaced_at = now;
}
await saveState(state);
return text({
unread: unread.map(({ update, ...meta }) => ({
...meta,
...(update
? {
update: {
id: update.id,
type: update.type,
title: update.title,
summary: update.summary,
body_markdown: update.body_markdown,
data: update.data,
actions: update.actions,
published_at: update.published_at,
},
}
: {}),
})),
remaining_unread: 0,
digest_path: digestPath(),
});
});
server.registerTool('hello_aigent_setup_watch', {
description: 'Set up a standing watch schedule so subscribed feeds are checked automatically (default cadence: ' +
'daily). Returns ready-to-apply recipes for the host platform — if you can create scheduled tasks ' +
'(Claude Code scheduled tasks, claude.ai or ChatGPT tasks, cron), apply the matching recipe now; ' +
'otherwise hand the instructions to your operator. Also persists the cadence to policy if given.',
inputSchema: {
cadence: z
.string()
.optional()
.describe("Watch cadence: 'hourly' | 'daily' | 'weekly' or e.g. '6h' (default: policy cadence, daily)"),
},
}, async ({ cadence }) => {
const policy = await loadPolicy();
if (cadence && cadence !== policy.watch_cadence) {
policy.watch_cadence = cadence;
await savePolicy(policy);
}
const effective = cadence ?? policy.watch_cadence;
const cmd = 'npx -y @helloaigent-dev/subscriber watch --once';
const cronLine = effective === 'hourly'
? `7 * * * * ${cmd}`
: effective === 'weekly'
? `7 9 * * 1 ${cmd}`
: `7 9 * * * ${cmd}`; // daily (and custom cadences default to daily cron; loop mode honors them exactly)
return text({
cadence: effective,
command: cmd,
note: 'One watch pass polls all active subscriptions, verifies signatures, and appends new updates to ' +
`the digest (${digestPath()}). Read it any time with hello_aigent_digest. The server-side mailbox ` +
'means a missed run loses nothing. Quotas bound polling (60s min interval, 30 fetches/hr per feed) — ' +
'hourly is the floor, daily is plenty for most feeds.',
recipes: {
claude_code: `If you are an agent with a task scheduler (e.g. Claude Code scheduled tasks): create a ${effective} ` +
`scheduled task that runs \`${cmd}\` and then reads hello_aigent_digest to summarize anything new.`,
claude_ai_or_chatgpt: `On claude.ai or ChatGPT, create a ${effective} recurring task with the instruction: ` +
'"Check my Hello Aigent digest (hello_aigent_digest via the subscriber MCP) and summarize anything new."',
cron: `crontab -e and add: ${cronLine}`,
long_running: `Or keep one process running: npx -y @helloaigent-dev/subscriber watch --every ${effective === 'daily' || effective === 'weekly' || effective === 'hourly' ? { hourly: '1h', daily: '24h', weekly: '7d' }[effective] : effective}`,
},
docs: 'https://helloaigent.dev/docs#watch',
policy_path: policyPath(),
});
});
// Persist any pre-1.0 state-file shape changes here if needed later.
void saveState;
// First run writes the default policy file so it is visible/editable from day one.
await loadPolicy();
const transport = new StdioServerTransport();
await server.connect(transport);

@@ -13,4 +13,10 @@ export interface StoredSubscription {

consent_scope: string;
/** How this subscription came to exist: 'explicit' opt-in or 'auto' (policy on a visit). */
origin?: 'explicit' | 'auto';
subscribed_at: string;
revoked_at?: string;
/** Usage-based decay (doc 17): last time this feed's updates were surfaced or explicitly fetched. */
last_surfaced_at?: string;
/** How many times the human/agent explicitly asked about this feed (bookkeeping for decay). */
ask_count?: number;
}

@@ -24,2 +30,23 @@ interface StateFile {

export declare function upsertSubscription(sub: StoredSubscription): Promise<void>;
export interface DigestEntry {
id: string;
kind: 'update' | 'system';
subscription_id: string;
feed_id: string;
feed_title: string;
received_at: string;
/** null = unread; set when hello_aigent_digest surfaces it (the decay signal). */
surfaced_at: string | null;
/** The verified envelope (kind 'update'). */
update?: Record<string, unknown>;
/** Watch bookkeeping lines, e.g. a decay prune (kind 'system'). */
note?: string;
}
interface DigestFile {
entries: DigestEntry[];
}
export declare function digestPath(): string;
export declare function loadDigest(): Promise<DigestFile>;
export declare function saveDigest(digest: DigestFile): Promise<void>;
export declare function appendDigestEntries(entries: DigestEntry[]): Promise<void>;
export {};

@@ -32,1 +32,24 @@ // Local subscriber state: a user-scoped JSON file holding one

}
export function digestPath() {
return process.env.HELLO_AIGENT_DIGEST ?? join(dirname(statePath()), 'digest.json');
}
export async function loadDigest() {
try {
return JSON.parse(await readFile(digestPath(), 'utf8'));
}
catch {
return { entries: [] };
}
}
export async function saveDigest(digest) {
const path = digestPath();
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(digest, null, 2) + '\n', { mode: 0o600 });
}
export async function appendDigestEntries(entries) {
if (!entries.length)
return;
const digest = await loadDigest();
digest.entries.push(...entries);
await saveDigest(digest);
}
{
"name": "@helloaigent-dev/subscriber",
"version": "0.1.1",
"version": "0.2.0",
"mcpName": "io.github.akillam/helloaigent-subscriber",

@@ -5,0 +5,0 @@ "description": "Hello Aigent reference subscriber — an MCP server that lets any agent subscribe to any Hello Aigent feed, fetch signed updates, verify them, and act.",

@@ -5,5 +5,7 @@ # @helloaigent-dev/subscriber

[Hello Aigent](https://helloaigent.dev) feed, fetch signed updates, verify them, and act on them.
Plus **watch mode**: a standing poller that collects verified updates into a digest between agent runs.
```bash
npx @helloaigent-dev/subscriber
npx @helloaigent-dev/subscriber # MCP server (stdio)
npx @helloaigent-dev/subscriber watch # standing watcher (default cadence: daily)
```

@@ -15,7 +17,38 @@

|---|---|
| `hello_aigent_subscribe(discovery_url, feed_id?, principal, consent_scope)` | Reads the site's `/.well-known/hello-aigent.json`, subscribes on behalf of your principal |
| `hello_aigent_subscribe(discovery_url, feed_id?, principal?, consent_scope?)` | Reads the site's `/.well-known/hello-aigent.json`, subscribes (defaults come from your policy) |
| `hello_aigent_fetch(subscription_id?, max?)` | Pulls only-new-since updates via the stored cursor; **verifies every envelope signature** |
| `hello_aigent_unsubscribe(subscription_id)` | Revokes consent (idempotent) |
| `hello_aigent_unsubscribe(subscription_id)` | Revokes consent (idempotent) — the one-call undo |
| `hello_aigent_list_subscriptions()` | Lists stored subscriptions (tokens are never exposed) |
| `hello_aigent_check_site(url)` | Checks a site you're visiting for a feed; auto-subscribes per your standing policy (`origin: auto`) |
| `hello_aigent_digest()` | Returns unread digest entries collected by `watch` and marks them surfaced |
| `hello_aigent_setup_watch(cadence?)` | Emits ready-to-apply standing-schedule recipes (scheduled task, recurring task, cron) |
## Watch mode
```bash
npx @helloaigent-dev/subscriber watch --once # one pass (what schedulers call)
npx @helloaigent-dev/subscriber watch --every 6h # long-running loop (floor: hourly)
npx @helloaigent-dev/subscriber watch --once --exec "my-agent-cmd" # trigger a run on new updates
```
Each pass polls every active subscription, verifies signatures, and appends new updates to the
digest file. The server-side mailbox means a missed run loses nothing. `--exec` runs your command
when new updates land, with `HELLO_AIGENT_NEW_UPDATES` and `HELLO_AIGENT_DIGEST` set.
## Policy
Written to `~/.hello-aigent/policy.json` on first run — everything automatic by default, and this
file is where you change that:
| Key | Default | Meaning |
|---|---|---|
| `principal` | `user@host` | Your stable identity across all feeds — set it once (e.g. your email) |
| `auto_subscribe` | `on` | Subscribe when your agent visits a Hello Aigent site: `on` / `ask` / `off` |
| `watch_cadence` | `daily` | How often watch polls (`hourly` floor) |
| `act` | `safe` | What the agent may do unprompted: `none` / `safe` (side-effect-free) / `thresholds` |
| `pseudonymous` | `false` | Opt-in: per-site pseudonymous principals |
Feeds nobody reads decay: after 30 idle days watch stops polling them; after 60 it unsubscribes
(noted in the digest). Reading the digest or fetching a feed keeps it alive.
## Guarantees

@@ -26,6 +59,7 @@

verification is returned under `unverified` with its `actions` stripped.
- **Consent is surfaced.** Subscribing records the `principal` and `consent_scope`; the tool
description instructs agents to confirm with their principal first.
- **Local state only.** Subscriptions (including bearer tokens) live in
`~/.hello-aigent/subscriptions.json` (mode 0600). Override with `HELLO_AIGENT_STATE`.
- **Consent is standing policy.** Subscribing records `principal` + `consent_scope`; your policy
file is the consent layer, and unsubscribe is always one idempotent call.
- **Local state only.** Subscriptions (including bearer tokens), policy, and the digest live in
`~/.hello-aigent/` (mode 0600). Override with `HELLO_AIGENT_STATE` / `HELLO_AIGENT_POLICY` /
`HELLO_AIGENT_DIGEST`.

@@ -32,0 +66,0 @@ ## MCP client config