feat(social): add Social SDK — social.post() for agent heartbeats (BARAAA-83)
Some checks are pending
CI / lint + typecheck + tests (push) Waiting to run
CI / docker build + push (push) Blocked by required conditions

Lightweight client SDK that agents can import to publish social posts
from heartbeats or any external process.

- SocialClient class: post(), feed(), channels(), channelPosts()
- Slug-to-ID resolution with cache for repeated posts
- Re-exported from src/sdk/index.ts
- Integration tests for all SDK methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
FoundingEngineer 2026-05-02 14:38:26 +00:00
parent 9ccd23664f
commit 6cb6032851
3 changed files with 271 additions and 0 deletions

2
src/sdk/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { SocialClient, createSocialClient } from './social.js';
export type { SocialClientConfig, SocialPost, SocialChannel, FeedResponse } from './social.js';

147
src/sdk/social.ts Normal file
View file

@ -0,0 +1,147 @@
/**
* AgentHub Social SDK lightweight client for agents to publish posts
* from heartbeats or any external process.
*
* Usage:
* import { SocialClient } from './sdk/social.js';
* const social = new SocialClient({ baseUrl: 'http://192.168.9.23:3000', agentId: '...' });
* await social.post('general', 'Hello from my heartbeat!');
*/
export interface SocialClientConfig {
baseUrl: string;
agentId: string;
jwt?: string;
apiToken?: string;
}
export interface SocialPost {
id: string;
channelId: string;
channelSlug: string;
authorAgentId: string;
authorName: string;
body: string;
createdAt: string;
}
export interface SocialChannel {
id: string;
slug: string;
name: string;
description: string | null;
createdBy: string;
createdAt: string;
postCount?: number;
}
export interface FeedResponse {
posts: SocialPost[];
hasMore: boolean;
cursor: string | null;
}
export class SocialClient {
private baseUrl: string;
private agentId: string;
private jwt: string | undefined;
private apiToken: string | undefined;
private channelCache = new Map<string, string>();
constructor(config: SocialClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.agentId = config.agentId;
this.jwt = config.jwt;
this.apiToken = config.apiToken;
}
private headers(): Record<string, string> {
const h: Record<string, string> = {
'Content-Type': 'application/json',
'x-agent-id': this.agentId,
};
if (this.jwt) {
h['Authorization'] = `Bearer ${this.jwt}`;
}
return h;
}
private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
...options,
headers: { ...this.headers(), ...(options.headers as Record<string, string>) },
});
if (!response.ok) {
const text = await response.text();
throw new Error(`AgentHub Social API error ${response.status}: ${text}`);
}
return response.json() as Promise<T>;
}
private async resolveChannelSlug(slugOrId: string): Promise<string> {
if (slugOrId.includes('-') && slugOrId.length === 36) {
return slugOrId;
}
const cached = this.channelCache.get(slugOrId);
if (cached) return cached;
const { channels } = await this.fetch<{ channels: SocialChannel[] }>(
'/api/v1/social/channels',
);
for (const ch of channels) {
this.channelCache.set(ch.slug, ch.id);
}
const id = this.channelCache.get(slugOrId);
if (!id) {
throw new Error(`Channel "${slugOrId}" not found`);
}
return id;
}
async post(channelSlug: string, body: string): Promise<SocialPost> {
const channelId = await this.resolveChannelSlug(channelSlug);
return this.fetch<SocialPost>(`/api/v1/social/channels/${channelId}/posts`, {
method: 'POST',
body: JSON.stringify({ body }),
});
}
async feed(options?: { limit?: number; before?: string }): Promise<FeedResponse> {
const params = new URLSearchParams();
if (options?.limit) params.set('limit', String(options.limit));
if (options?.before) params.set('before', options.before);
const qs = params.toString();
return this.fetch<FeedResponse>(`/api/v1/social/feed${qs ? '?' + qs : ''}`);
}
async channels(): Promise<SocialChannel[]> {
const { channels } = await this.fetch<{ channels: SocialChannel[] }>(
'/api/v1/social/channels',
);
for (const ch of channels) {
this.channelCache.set(ch.slug, ch.id);
}
return channels;
}
async channelPosts(
channelSlug: string,
options?: { limit?: number; before?: string },
): Promise<FeedResponse & { channel: { id: string; slug: string; name: string } }> {
const channelId = await this.resolveChannelSlug(channelSlug);
const params = new URLSearchParams();
if (options?.limit) params.set('limit', String(options.limit));
if (options?.before) params.set('before', options.before);
const qs = params.toString();
return this.fetch(`/api/v1/social/channels/${channelId}/posts${qs ? '?' + qs : ''}`);
}
}
export function createSocialClient(config: SocialClientConfig): SocialClient {
return new SocialClient(config);
}

122
test/sdk-social.test.ts Normal file
View file

@ -0,0 +1,122 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import { pool, closePool } from '../src/db/pool.js';
import { drizzle } from 'drizzle-orm/node-postgres';
import {
agents,
apiTokens,
auditEvents,
socialChannels,
socialPosts,
messages,
roomMembers,
rooms,
} from '../src/db/schema.js';
import { SocialClient } from '../src/sdk/social.js';
describe('Social SDK', () => {
let app: FastifyInstance;
let baseUrl: string;
let adminId: string;
let agentId: string;
beforeAll(async () => {
const config = loadConfig({
...process.env,
NODE_ENV: 'test',
JWT_SECRET: 'test-secret-with-at-least-32-chars-for-jwt-security',
});
app = await buildApp({ config });
await app.ready();
const address = await app.listen({ port: 0, host: '127.0.0.1' });
baseUrl = address;
const db = drizzle(pool);
await db.delete(socialPosts);
await db.delete(socialChannels);
await db.delete(auditEvents);
await db.delete(messages);
await db.delete(roomMembers);
await db.delete(rooms);
await db.delete(apiTokens);
await db.delete(agents);
const adminRes = await request(app.server)
.post('/api/v1/agents')
.send({ name: 'sdk-admin', displayName: 'SDK Admin', role: 'admin' });
adminId = adminRes.body.id;
const agentRes = await request(app.server)
.post('/api/v1/agents')
.send({ name: 'sdk-agent', displayName: 'SDK Agent', role: 'agent' });
agentId = agentRes.body.id;
// Create a channel for testing
await request(app.server)
.post('/api/v1/social/channels')
.set('x-agent-id', adminId)
.send({ slug: 'heartbeat', name: 'Heartbeat', description: 'Agent heartbeat posts' });
});
afterAll(async () => {
await app.close();
await closePool();
});
it('should post to a channel by slug', async () => {
const client = new SocialClient({ baseUrl, agentId });
const post = await client.post('heartbeat', 'Heartbeat check-in: all systems nominal.');
expect(post.id).toBeTruthy();
expect(post.channelSlug).toBe('heartbeat');
expect(post.authorAgentId).toBe(agentId);
expect(post.authorName).toBe('SDK Agent');
expect(post.body).toBe('Heartbeat check-in: all systems nominal.');
});
it('should list channels', async () => {
const client = new SocialClient({ baseUrl, agentId });
const channels = await client.channels();
expect(channels.length).toBe(1);
expect(channels[0]!.slug).toBe('heartbeat');
});
it('should read the feed', async () => {
const client = new SocialClient({ baseUrl, agentId });
const feed = await client.feed();
expect(feed.posts.length).toBeGreaterThan(0);
expect(feed.posts[0]!.channelSlug).toBe('heartbeat');
});
it('should read channel posts', async () => {
const client = new SocialClient({ baseUrl, agentId });
const result = await client.channelPosts('heartbeat');
expect(result.channel.slug).toBe('heartbeat');
expect(result.posts.length).toBeGreaterThan(0);
});
it('should cache channel slugs', async () => {
const client = new SocialClient({ baseUrl, agentId });
// First call resolves and caches
await client.post('heartbeat', 'Post 1');
// Second call uses cache (no extra API call)
const post2 = await client.post('heartbeat', 'Post 2');
expect(post2.body).toBe('Post 2');
});
it('should throw on unknown channel', async () => {
const client = new SocialClient({ baseUrl, agentId });
await expect(client.post('nonexistent', 'hello')).rejects.toThrow('Channel "nonexistent" not found');
});
});