npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 per oauth/start call) — 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) — uses POST /v2/socialActions/{shareUrn}/likes. Covered by the basic member-social posting scope.
    • PRAISE / EMPATHY / INTEREST / APPRECIATION / ENTERTAINMENT — use POST /rest/reactions. Requires additional LinkedIn permissions on the configured app; returns REACTION_FORBIDDEN if 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 expired and surface REFRESH_FAILED to the caller. AUTH_REVOKED from LinkedIn marks the connection revoked.

Analytics page

  • Tracked posts — every post the plugin publishes via linkedin-post is 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 a lead row 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 transitions new → dm_drafted.
  • Lifecyclenewdm_draftedcontacted (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 with archived_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/recent and the plugin UI.
  • Daily reconciliation (reconcile-connections cron, 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.com

The 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 against accountDisplayName and accountHandle.
  • 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_LIMITED with retryAfterSeconds, 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 URN
    • reactionType?: "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 as linkedin-post.

linkedin-comment

Post a top-level text comment on a LinkedIn post. Non-idempotent.

  • Parameters:
    • connectionId: string (required)
    • post: string (required) — URL or URN
    • text: string (required, maxLength 1250)
    • mentions?: Array<{ start: number; length: number; urn: string }> — inline mentions. Each [start, start+length) must fit within text and entries must not overlap. URNs must be urn:li:person:... or urn: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 (with retryAfterSeconds), 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=…&region=… | { 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-plugin

Local-path development

paperclipai plugin install <path-to-plugin-checkout> --local

After install

  1. Open the plugin's settings page in the Paperclip admin UI.
  2. Configure dekRef, redirectBaseUrl, and (for LinkedIn) clientIdRef / clientSecretRef.
  3. From the Social Accounts page, click Connect LinkedIn — completes the OAuth flow and creates a connection.
  4. (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.
  5. (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 presets

Integration test

Runs against a live Paperclip:

PAPERCLIP_BASE_URL=http://127.0.0.1:3100 \
  pnpm test tests/integration/pluginLifecycle.spec.ts

Skipped without PAPERCLIP_BASE_URL.