@blaze283/social-agent-plugin
v0.7.3
Published
A Paperclip plugin
Downloads
2,193
Readme
@blaze283/social-agent-plugin
A Paperclip plugin that gives agents the ability to publish to and react on social channels on behalf of connected human accounts, and surfaces engager-level analytics on the company's tracked posts. The plugin owns OAuth credentials, refreshes tokens at request time, encrypts tokens at rest, and makes the actual outbound HTTP calls to channel APIs. Agents drive it via tools; the operator drives analytics refresh via a paperclip routine.
LinkedIn is the only channel today; the architecture is multi-channel — additional channels plug in as adapters without schema rewrites or manifest changes.
What it does
LinkedIn integration
OAuth 3-legged flow (default scopes
openid,profile,email,w_member_social; overridable peroauth/startcall) — start, complete, persist a connection per LinkedIn person URN.Multi-account per company — a single Paperclip company can have multiple LinkedIn connections (different employees, different LinkedIn pages-as-people).
Personal-feed publishing (
linkedin-post) — text posts, public or connections-only, up to 3000 characters.Post reactions (
linkedin-react):LIKE(default) — usesPOST /v2/socialActions/{shareUrn}/likes. Covered by the basic member-social posting scope.PRAISE/EMPATHY/INTEREST/APPRECIATION/ENTERTAINMENT— usePOST /rest/reactions. Requires additional LinkedIn permissions on the configured app; returnsREACTION_FORBIDDENif the app lacks them.
Idempotent on 409/422 ("already reacted") via heuristic body match. Accepts URLs or URNs (
urn:li:activity:…,urn:li:share:…,urn:li:ugcPost:…,https://www.linkedin.com/feed/update/…,https://www.linkedin.com/posts/<slug>-activity-<id>-<hash>/).Post comments (
linkedin-comment) — top-level text comments with optional inline @-mentions when the caller supplies target URNs. Non-idempotent; the agent decides whether to retry.Token refresh — automatically within 7 days of expiry, using the encrypted refresh token; failures mark the connection
expiredand surfaceREFRESH_FAILEDto the caller.AUTH_REVOKEDfrom LinkedIn marks the connectionrevoked.
Analytics page
- Tracked posts — every post the plugin publishes via
linkedin-postis tracked automatically. Operators paste any LinkedIn URL on the Analytics page to track an external post. - Engager classification — engagers (reactors + commenters) are classified by ICP (
lead/influencer/competitor/none) and region (us/eu/apac/other). Classification is performed by an operator-configured paperclip routine. - Triage workflow — each engager has a per-board triage state (
none/saved/dismissed/contacted), edited inline in the Analytics page. - Daily metric buckets — reactions, comments, leads, and influencers bucketed by day over the last 7 / 14 / 30 days, with sparkline rendering on metric cards.
- Operator-driven refresh — the actual data collection (LinkedIn scraping, ICP classification) runs in a paperclip routine that the operator configures. The plugin provides agent-auth endpoints for the routine to write into; the SettingsPage section walks the operator through the setup with a copyable routine prompt.
Leads page
- One lead per person — when an engager is classified
icp_class='lead', the plugin upserts aleadrow keyed by(company_id, profile_urn)so multiple engagements on different posts collapse into a single lead. - Issue-per-lead — on first classification, the plugin creates a
[LEAD] {name}paperclip issue under the configured Leads project, assigned to a configured agent. The issue body carries the engagement context and the exact agent-auth endpoint for writing back drafts. - Cold-DM drafting — the assignee agent reads the lead context and POSTs three cold-DM variants back to
/leads/agent/:leadId/dm-suggestions. Drafts are stored on the lead row; the lead transitionsnew → dm_drafted. - Lifecycle —
new→dm_drafted→contacted(operator confirms the message went out). Archive on demand; restore when a fresh lead-classified engagement reappears for the same profile. - Auto-archive on orphan — if the last
icp_class='lead'engagement for a profile is reclassified away, the lead is auto-archived witharchived_reason='orphaned'and the underlying issue is cancelled (best-effort).
Cross-channel
- Token-at-rest encryption — AES-256-GCM envelope under a per-instance DEK (config
dekRef). Tokens are never plaintext on disk; AAD binds ciphertext to(companyId, ownerUserId, channel). - Audit log — every attempt/success/failure/disconnect/analytics-event is appended to a per-company append-only audit log surfaced through
/audit/recentand the plugin UI. - Daily reconciliation (
reconcile-connectionscron,0 3 * * *) — sweeps connections approaching expiry, flags revoked tokens, refreshes where it can. - Plugin UI — Social Accounts page (
/social), Analytics page (/analytics), Leads page (/leads), OAuth callback (/social-callback), settings page, three sidebar entries.
Architecture
agent (Claude Code, etc.)
│ stdio MCP
▼
@blaze283/social-agent-mcp ← thin wrapper
│ HTTP, agent's bearer token
▼
POST /api/plugins/<pluginId>/api/tools/dispatch ← this plugin, auth: "agent"
│ in-process RPC into the worker
▼
plugin worker (this package)
│ AES-GCM decrypt → access token
▼
api.linkedin.comThe plugin is the credential vault. Tokens never leave the worker process unencrypted; OAuth flows, refresh, and outbound channel calls all happen here.
The analytics refresh routine runs outside the plugin in its own paperclip routine. It reads/writes through the agent-auth analytics endpoints (/analytics/agent/...).
Tools
Declared in manifest.tools and registered via ctx.tools.register(). Each is also reachable through the agent-auth dispatch route.
social-list-channels
Returns the channels this plugin's adapter registry supports and whether each is enabled in the current Paperclip instance.
- Parameters: none.
- Returns:
{ data: { channels: Array<{ channel, displayName, enabled, postToolName }> } }
social-list-connections
Lists connected accounts on a channel within the current company.
- Parameters:
channel: string(required, e.g."linkedin")status?: "active" | "expired" | "revoked" | "any"— default"active"match?: string— case-insensitive substring matched againstaccountDisplayNameandaccountHandle.
- Returns:
{ data: { ok: true, channel, connections: Array<{ connectionId, channel, externalUrn, accountDisplayName, accountHandle, accountAvatarUrl, ownerUserId, status, scopes, expiresAt, lastRefreshedAt }> } } - Errors (in
data):UNKNOWN_CHANNEL,NOT_CONFIGURED.
linkedin-post
Publish a text post to a connected LinkedIn account. Posts immediately and publicly by default. Confirm with the user before invoking.
- Parameters:
connectionId: string(required)text: string(required, maxLength 3000)visibility?: "PUBLIC" | "CONNECTIONS"— default"PUBLIC"issueId?: string— optional, for audit linkage
- Returns:
{ data: { ok: true, postUrl, externalPostId } } - Errors (in
data):BAD_REQUEST,CHANNEL_NOT_CONFIGURED,CONNECTION_NOT_FOUND,CONNECTION_EXPIRED,CONNECTION_REVOKED,REFRESH_FAILED, plus LinkedIn-specific codes (AUTH_REVOKED,RATE_LIMITEDwithretryAfterSeconds,INVALID_REQUEST,SERVER_ERROR, etc.).
linkedin-react
Apply a reaction to a LinkedIn post. Idempotent — repeated calls with the same (connection, post, reactionType) return alreadyReacted: true rather than failing.
- Parameters:
connectionId: string(required)post: string(required) — URL or URNreactionType?: "LIKE" | "PRAISE" | "EMPATHY" | "INTEREST" | "APPRECIATION" | "ENTERTAINMENT"— default"LIKE"issueId?: string— optional, for audit linkage
- Returns:
{ data: { ok: true, postUrn, reactionType, alreadyReacted, reactionId } } - Errors (in
data):BAD_REQUEST,BAD_POST_REFERENCE,CHANNEL_NOT_CONFIGURED,CONNECTION_NOT_FOUND,CONNECTION_EXPIRED,CONNECTION_REVOKED,REFRESH_FAILED, plus the same LinkedIn-specific codes aslinkedin-post.
linkedin-comment
Post a top-level text comment on a LinkedIn post. Non-idempotent.
- Parameters:
connectionId: string(required)post: string(required) — URL or URNtext: string(required, maxLength 1250)mentions?: Array<{ start: number; length: number; urn: string }>— inline mentions. Each[start, start+length)must fit withintextand entries must not overlap. URNs must beurn:li:person:...orurn:li:organization:.... The tool does not resolve names to URNs.issueId?: string— optional, for audit linkage
- Returns:
{ data: { ok: true, postUrn, commentUrn, commentId, commentText, commentUrl } } - Errors (in
data):BAD_REQUEST,BAD_POST_REFERENCE,CHANNEL_NOT_CONFIGURED,CONNECTION_NOT_FOUND,CONNECTION_EXPIRED,CONNECTION_REVOKED,REFRESH_FAILED,AUTH_REVOKED,RATE_LIMITED(withretryAfterSeconds),PROVIDER_UNAVAILABLE,POST_NOT_FOUND,COMMENT_FORBIDDEN,COMMENT_FAILED.
HTTP routes
All routes are mounted under /api/plugins/<pluginId>/api/<path>. The platform applies assertScopedApiAuth (auth mode), resolveScopedApiCompanyId (companyResolution), and assertCompanyAccess (cross-company protection) before handlers run.
Board routes (require actor.type === "board" with company access)
| Path | Method | Body / Query | Returns |
|---|---|---|---|
| /oauth/start | POST | { companyId, channel, redirectPath?, scopes?, state? } | { ok, authzUrl, state, expiresAt } |
| /oauth/complete | POST | { companyId, channel, code, state } | { ok, connection: { connectionId, externalUrn, accountDisplayName, … } } |
| /connections/:connectionId/disconnect | POST | { companyId } | { ok } |
| /me/connections | GET | ?companyId=…&channel=…&includeRevoked=true | { ok, connections: [...] } |
| /team/connections | GET | ?companyId=…&channel=linkedin&status=active | { ok, connections: [...], audit?: [...] } |
| /audit/recent | GET | ?companyId=… | { ok, entries: [...] } |
| /analytics/posts | GET | ?companyId=…&source=published\|tracked_external\|all | { ok, posts: [...] } |
| /analytics/posts/:id | GET | ?companyId=…&since=7d\|14d\|30d&icpClass=…®ion=… | { ok, post, metrics, engagers, daily } |
| /analytics/posts/:id/engagers/:engagerId/triage | POST | { companyId, state } | { ok } |
| /analytics/track-url | POST | { companyId, url } | { ok, trackedPostId, alreadyTracked } |
| /analytics/projects | GET | ?companyId=… | { ok, projects: [...] } |
| /analytics/agents | GET | ?companyId=… | { ok, agents: [...] } |
Agent routes (require actor.type === "agent")
| Path | Method | Body / Query | Returns |
|---|---|---|---|
| /tools/dispatch | POST | { companyId, tool, parameters, projectId? } | ToolResult |
| /analytics/agent/tracked-posts | GET | ?companyId=…&status=pending\|ready\|any&since=…&limit=…&channel=… | { ok, posts: [...] } |
| /analytics/agent/tracked-posts | POST | { companyId, posts: [...] } | { ok, results: [...] } |
| /analytics/agent/posts/:id/engagers | POST | { companyId, engagers: [...] } | { ok, upserted } |
| /analytics/agent/posts/:id/snapshot | POST | { companyId, sampledAt, reactionCount, commentCount, status? } | { ok } |
Error envelope
Failed routes return:
{ "ok": false, "code": "<MACHINE_CODE>", "message": "<human readable>" }Codes: BAD_REQUEST, NOT_FOUND, FORBIDDEN, NOT_OWNER, NOT_ADMIN, CHANNEL_NOT_CONFIGURED, URN_ALREADY_CONNECTED, CONNECTION_NOT_FOUND, INTERNAL. The platform may add envelope codes (UNKNOWN_ROUTE, etc.) ahead of these.
Agent dispatch route
POST /api/plugins/<pluginId>/api/tools/dispatch exists because Paperclip's generic POST /api/plugins/tools/execute is currently board-only. Agents authenticated with their own keys cannot call any plugin's tools through the standard surface, so this plugin exposes a sibling auth: "agent" route that delegates into the same tool inner functions (runLinkedInPost, runLinkedInReact, runLinkedInComment, runSocialListChannels, runSocialListConnections).
Instance configuration
Set in the Paperclip plugin admin UI under the plugin's settings page. Schema is also in src/manifest.ts.
| Key | Type | Required | Purpose |
|---|---|---|---|
| dekRef | secret-ref | yes | Reference to a 32-byte base64-encoded data-encryption key. Used to envelope OAuth tokens at rest. Per-row keyVersion supports rotation. |
| redirectBaseUrl | string | yes | Public origin of this Paperclip instance, e.g. https://paperclip.example.com. Used to build OAuth redirect URIs. |
| channels.linkedin.enabled | boolean | no | Toggles the LinkedIn channel. Disabled by default. |
| channels.linkedin.clientIdRef | secret-ref | when LinkedIn enabled | LinkedIn application client id. |
| channels.linkedin.clientSecretRef | secret-ref | when LinkedIn enabled | LinkedIn application client secret. |
| channels.analytics.issueProjectId | string | when analytics used | Paperclip project that holds analytics issues (one master issue + one per tracked post). |
| channels.analytics.issueAssigneeAgentId | string | no | Default assignee on auto-created tracked-post issues. |
| channels.analytics.refreshWebhookUrl | string | no | Webhook fired (best-effort) when a new URL is pasted, so the refresh routine can pick it up immediately. |
| channels.analytics.refreshWebhookSecretRef | secret-ref | when webhook used | Secret used to authenticate the webhook call. |
| channels.analytics.refreshWebhookAuthMode | "bearer" \| "hmac_sha256" | no | How to attach the secret. Defaults to bearer. |
Secret references resolve through ctx.secrets.resolve() at request time. Plaintext secrets never live in plugin state.
Database
Owns the social namespace (manifest declares database.namespaceSlug: "social").
| File | Contents |
|---|---|
| 0001_social_connections.sql | Connections table. |
| 0002_relax_employee_id.sql | Drops the not-null on the legacy employee id column. |
| 0003_multi_account.sql | Multiple connections per (company, channel, externalUrn). |
| 0004_analytics.sql | tracked_post, post_engager, post_snapshot. |
The plugin reads companies from the core schema (coreReadTables: ["companies"]). It does not write to any core table.
Background jobs
| Job | Schedule | What |
|---|---|---|
| reconcile-connections | 0 3 * * * (daily at 03:00 server time) | Sweeps connections nearing expiry. Refreshes tokens via the channel adapter where possible; marks expired on refresh failure; emits an audit entry per action. |
Manually triggerable through POST /api/plugins/:pluginId/jobs/:jobId/trigger.
The analytics refresh is not a plugin job. It is a paperclip routine the operator configures (see the Analytics section of the plugin settings page for the prompt and setup steps); the plugin only provides the read/write endpoints.
UI
| Slot | Type | Path / Anchor | Purpose |
|---|---|---|---|
| social | page | /social | Per-user view of own connections; connect / disconnect / list recent posts. |
| analytics | page | /analytics | Two-column engager analytics on tracked posts: post list (left, with URL paste) + post detail (right, metrics with sparklines, ICP / region filters, engager table with triage controls). |
| oauth-callback | page | /social-callback | Receives the OAuth redirect, posts to /oauth/complete. |
| settings | settingsPage | (in plugin admin) | Configure dekRef, redirectBaseUrl, channel client ids/secrets, and the analytics routine setup (project picker, agent picker, webhook URL/secret, copyable routine prompt). |
| sidebar | sidebar | — | Top-level "Social" nav entry. |
| sidebar-analytics | sidebar | — | Top-level "Analytics" nav entry. |
UI bundle is built with esbuild from src/ui/ and exposed via manifest.entrypoints.ui = "./dist/ui".
Capabilities
Declared in manifest.capabilities:
| Capability | Why this plugin needs it |
|---|---|
| companies.read | Resolve companyId against the company table. |
| activity.read, activity.log.write | Surface and write structured activity entries. |
| plugin.state.read, plugin.state.write | Pending OAuth state; small key/value bookkeeping. |
| database.namespace.migrate, database.namespace.read, database.namespace.write | Own the social schema. |
| http.outbound | Call api.linkedin.com, OAuth token endpoints, the analytics refresh webhook. |
| secrets.read-ref | Resolve dekRef, clientIdRef, clientSecretRef, refreshWebhookSecretRef. |
| agent.tools.register | Register the four agent tools. |
| jobs.schedule | Schedule the daily reconciliation job. |
| api.routes.register | Mount the HTTP routes. |
| ui.page.register, ui.sidebar.register, instance.settings.register | UI slots. |
| issues.create | Auto-create a paperclip issue when an operator pastes a tracked URL. |
| projects.read, agents.read | Populate the project / agent pickers on the analytics settings page. |
Install
Production / npm
curl -X POST http://127.0.0.1:3100/api/plugins/install \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <board api key>" \
-d '{"packageName":"@blaze283/social-agent-plugin"}'Or via the Paperclip CLI:
paperclipai plugin install @blaze283/social-agent-pluginLocal-path development
paperclipai plugin install <path-to-plugin-checkout> --localAfter install
- Open the plugin's settings page in the Paperclip admin UI.
- Configure
dekRef,redirectBaseUrl, and (for LinkedIn)clientIdRef/clientSecretRef. - From the Social Accounts page, click Connect LinkedIn — completes the OAuth flow and creates a connection.
- (Optional, for Analytics) On the settings page, configure the analytics block: pick a project, pick the refresh agent, paste a webhook URL/secret, then copy the routine prompt into a new paperclip routine assigned to that agent.
- (Optional, for Leads) On the settings page, configure the Leads block: pick a project for lead issues and a lead-DM assignee agent. Copy the lead-DM agent prompt into that agent's working instructions and replace the voice/ICP block with company-specific guidance.
Development
pnpm install
pnpm dev # esbuild watch
pnpm dev:ui # local UI dev server with hot-reload events
pnpm test # vitest, all I/O mocked
pnpm typecheck
pnpm build # production bundle
pnpm build:rollup # alternate bundler, same SDK presetsIntegration test
Runs against a live Paperclip:
PAPERCLIP_BASE_URL=http://127.0.0.1:3100 \
pnpm test tests/integration/pluginLifecycle.spec.tsSkipped without PAPERCLIP_BASE_URL.
