spora-ai / spora-plugin-social
Native social publishing for LinkedIn, Facebook and Instagram — connect accounts, write and schedule posts with an agent, and read the numbers back.
Package info
github.com/spora-ai/spora-plugin-social
Type:spora-plugin
pkg:composer/spora-ai/spora-plugin-social
Requires
- php: ^8.4.1
- league/oauth2-client: ^2.9
- spora-ai/spora-core: >=0.32.0 <2.0.0
- spora-ai/spora-plugin-social-frontend: >=0.1.0 <2.0.0
- symfony/http-client: ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- larastan/larastan: ^3.9
- mockery/mockery: ^1.6
- pestphp/pest: ^5.0
- phpstan/phpstan: ^2.1
- phpstan/phpstan-mockery: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-13 21:57:26 UTC
README
Native social publishing for LinkedIn, Facebook and Instagram — and, through an optional aggregator, for nine more networks.
Connect your own accounts, write one post for every network, let an agent
schedule it, and see how each one did. This plugin replaces
spora-plugin-zernio, which was a thin proxy to a third-party service that
owned the tokens, the scheduling and the publishing. The aggregator route is
the plugin's own now: it speaks Zernio's API directly, and the operator's key
stays here.
What it does
- Connect accounts. One login per provider. Meta is one login for both networks: the Instagram API with Unified Login, built on Facebook Login for Business. The client picks a business portfolio, the Instagram professional account and the Facebook Page, and the app gets one system-user token that covers every asset they shared. It has no refresh grant, so it is reconnected rather than renewed, and whether it expires at all is the configuration's decision — a Meta token can carry an expiry, and the expiry the platform sends is the expiry the connection keeps. LinkedIn keeps its own flow: OpenID Connect, and one token that covers the member and the organisations they administer. One Instagram caveat the plugin cannot check for you: an account connected to a Page that requires Page Publishing Authorization cannot be published to until PPA is completed, and Meta says there is no way for an app to detect that.
- Reach more networks. An optional aggregator — Zernio — publishes on twelve networks: X, Instagram, Facebook, LinkedIn, TikTok, YouTube, Pinterest, Reddit, Bluesky, Threads, Google Business and Telegram. Nine of them the plugin does not reach itself, and the other three it reaches with Zernio's limits rather than its own. The operator pastes one API key, Zernio's accounts become publishable targets, and the plugin publishes them as one cross-post.
- Publish. One post, aimed at several accounts. The outcome is recorded
per account, so a post that reached LinkedIn and not Instagram is
partial, notfailed. - Write. The composer's Draft with AI button hands the request to an
agent, which writes the post with the
social_posttool and saves it as a draft the operator can edit in the panel. - Take down. A published post can be pulled back down, one network at a time. LinkedIn and Facebook can delete a published post; Instagram cannot — the delete endpoint exists only under Instagram's Facebook Login, and this plugin speaks the Unified Login, so the API has no delete for published media there — so that target stays published and says why.
- Schedule. The agent writes the copy, the operator approves the copy and the time, and the worker publishes it when the moment comes. The agent-driven mode is the headline: it is the reason the plugin exists.
- Measure. Each platform's own insights API, normalised into one set of counters with the platform's own numbers kept beside them. The networks stay apart — a summed figure is labelled a rough one.
There is no fixed slot — no "post the same text every Tuesday". It published the same words every week, and LinkedIn and Meta ship native scheduled posts now, so a third-party static repeater competed on the weakest axis available. The agent-driven mode replaced it.
The two routes
A target's identity is the network and the route to it. The same network reached through two routes is two accounts with two tokens, and a post that reaches both carries a copy of its own for each. The route travels with the target, so the plugin never publishes to a network twice by accident and never merges two networks' numbers into one row.
| Route | What it is | Which networks |
|---|---|---|
direct |
The plugin's own OAuth and the networks' own APIs. | LinkedIn, Facebook, Instagram. |
zernio |
Zernio's API. One API key for the whole team. | X, Instagram, Facebook, LinkedIn, TikTok, YouTube, Pinterest, Reddit, Bluesky, Threads, Google Business, Telegram. |
The direct path publishes one target at a time, as it always did. The aggregator's targets go out together: one call, one cross-post, one idempotency key for the group. The key is written to every target of the group before the call, so a publish that dies halfway is replayed under the same key — and the aggregator answers with the original post instead of publishing a second one.
The aggregator is read-only. The operator creates, renames, moves and disconnects accounts in Zernio's own dashboard; the plugin only learns what is there. An account the aggregator no longer reports is marked disconnected, not deleted — a post's history keeps its target.
The API key is the operator's. It is pasted on the Accounts screen, held on the connection row like a token, and replaced by pasting a new one. The agent's tools have no way to write a credential. An aggregator's key has no refresh grant and no expiry: it is replaced, not renewed.
The aggregator's routes
| Route | What it does |
|---|---|
POST /api/v1/plugins/social/aggregators |
Store the API key the operator pasted. |
DELETE /api/v1/plugins/social/aggregators/{slug} |
Forget the key. The accounts stay in Zernio and are marked disconnected here. |
POST /api/v1/plugins/social/aggregators/{slug}/sync |
Read the aggregator's accounts again. |
social_connections with action sync does the same read for an agent,
and never writes a credential.
The agent template and the skill
The plugin ships both:
agent-templates/social-poster.json— an agent that posts for the operator on a schedule. Its system prompt carries the per-platform rules, because an agent that writes 3,000 characters for Instagram or forgets the media is worse than no agent at all. Reads and drafts are pre-approved; anything that publishes is not.skills/social-poster/SKILL.md— the operating manual: what each tool does, the per-platform rules, the media rules, the approval behaviour, and the worked flow from "the operator asked for a weekly post" to "the post is scheduled".
Operator prerequisites
| Setting | Why |
|---|---|
SPORA_SOCIAL_LINKEDIN_CLIENT_ID / _SECRET |
Your LinkedIn app. Member posting needs no review; organisation pages need a second app, because the Community Management API has to be the only product on an app. The setting's own description carries the redirect URL to register with it. |
SPORA_SOCIAL_LINKEDIN_MEMBER_SCOPE |
On by default. Turn it off for an app that cannot post as a member. |
SPORA_SOCIAL_LINKEDIN_SIGN_IN_SCOPE |
On by default. Turn it off for a Community Management API app, which cannot serve OpenID Connect — asking for it refuses the whole request. The member is then named by the access-control list instead, so SPORA_SOCIAL_LINKEDIN_READ_ORG_ACLS has to be on as well. |
SPORA_SOCIAL_META_APP_ID / _SECRET |
Your Meta app — one app for both networks. pages_manage_posts needs the app reviewed. |
SPORA_SOCIAL_META_CONFIG_ID |
The id of the Facebook Login for Business configuration the login dialog uses: the permissions and assets your app asks a client for. Not a secret, but the login cannot happen without it. The setting's own description carries the redirect URL to register with it. |
SPORA_SOCIAL_LINKEDIN_ORGANIZATION_SCOPE |
Turn on only once your app holds w_organization_social — asking for a scope the app does not have makes the whole authorization request fail. |
SPORA_SOCIAL_LINKEDIN_READ_ORG_ACLS |
Turn on only once your app holds r_organization_admin (the organisations the member administers). One setting per permission, because an app that asks for one it does not hold is refused the whole request. It is also what names the member when Ask LinkedIn for the member's identity is off. |
SPORA_SOCIAL_LINKEDIN_READ_ORG_STATS |
Turn on only once your app holds rw_organization_admin (an organisation's share statistics). |
SPORA_SOCIAL_LINKEDIN_READ_MEMBER_FEED |
Turn on only once your app holds r_member_social_feed (a member post's reactions). |
SPORA_SOCIAL_LINKEDIN_READ_ORG_FEED |
Turn on only once your app holds r_organization_social_feed (an organisation post's reactions). |
SPORA_SOCIAL_LINKEDIN_API_VERSION |
The LinkedIn API version, in YYYYMM. Defaults to the current one; LinkedIn versions its APIs quarterly, so a fixed version stops working on a date nobody chose. |
SPORA_SOCIAL_GRAPH_API_VERSION |
The Meta Graph API version every Facebook and Instagram endpoint carries. Defaults to the current one; Meta retires a version every year. |
SPORA_SOCIAL_FRONTEND_URL |
Where the browser lands after an account is connected. Defaults to SPORA_APP_URL; set it when the SPA runs on another origin, as it does in local development. |
SPORA_APP_URL |
Must be publicly reachable over HTTPS. Instagram fetches the published media from the internet, so an address only your network can see fails at the last step of a publish. The plugin checks this at connect time, and GET /config reports it as app_url_public. The OAuth redirect URI is built from it, and the platforms need that address registered — so it has to be the address you registered, not the one the request happened to arrive on. |
The aggregator needs no setting: its API key is held on the connection
row, like a token, and the operator pastes it on the Accounts screen. The
aggregator fetches the media itself, from the public URL the plugin serves
it, so SPORA_APP_URL has to be reachable from the internet for that route
too.
Checking it against the real platforms
The adapter tests answer with hand-written responses, so they prove the plugin builds the request the way the code already understood it. They cannot prove the request is right — and things have been wrong that only the documentation could have told us. The live suite is the answer to that: it signs in for real, publishes a post on every account it can reach, reads the numbers back and takes the post down again where the platform allows it.
It runs only when you ask for it, because it needs credentials and the
network, and it is excluded from composer test. Run it from the plugin's
directory, after composer install.
composer live
The run prints the redirect URI to register, the authorization URL of every platform you gave it credentials for, and asks you to paste back the URL the platform sent you to. Then it prints a verdict per platform:
linkedin PASSED 7 passed, 0 failed, 0 skipped
facebook FAILED 3 passed, 1 failed, 2 skipped
instagram SKIPPED 0 passed, 0 failed, 5 skipped
skipped: SPORA_LIVE_META_CLIENT_ID is not in the environment
A skipped check is not a passed check. The command exits non-zero when nothing was verified, so a run without credentials cannot be mistaken for a green one.
The variables it reads
The names carry LIVE, so it is visible that they belong to the harness
and not to the install — the plugin's own settings are SPORA_SOCIAL_*
and the live suite never reads those.
| Variable | What it is |
|---|---|
SPORA_LIVE_LINKEDIN_CLIENT_ID / SPORA_LIVE_LINKEDIN_CLIENT_SECRET |
Your LinkedIn app's credentials. |
SPORA_LIVE_META_CLIENT_ID / SPORA_LIVE_META_CLIENT_SECRET / SPORA_LIVE_META_CONFIG_ID |
Your Meta app's credentials and the configuration id. Facebook and Instagram are one login now. |
SPORA_LIVE_META_REDIRECT_URL / SPORA_LIVE_LINKEDIN_REDIRECT_URL |
The url you landed on, when the run is a second one. Named after the provider; the platform spellings (SPORA_LIVE_FACEBOOK_REDIRECT_URL, SPORA_LIVE_INSTAGRAM_REDIRECT_URL) are still accepted. |
SPORA_LIVE_REDIRECT_URI |
The public address of your install, e.g. https://spora.example.com. The per-platform callback is built from it exactly as the plugin builds it. |
SPORA_LIVE_PUBLIC_IMAGE_URL |
A publicly reachable JPEG for Instagram to fetch. Instagram has no text-only post, and it takes no PNG. |
SPORA_LIVE_LINKEDIN_ORGANIZATION_SCOPE |
Set it once your app holds the organisation scopes. |
SPORA_LIVE_LINKEDIN_READ_ORG_ACLS / _READ_ORG_STATS / _READ_MEMBER_FEED / _READ_ORG_FEED |
Set the one once your app holds that read permission. |
SPORA_LIVE_GRAPH_API_VERSION / SPORA_LIVE_LINKEDIN_API_VERSION |
The versions to check against, when they differ from the plugin's defaults. |
SPORA_LIVE_<PLATFORM>_REDIRECT_URL |
The URL the platform sent you back to, when you collected it in an earlier run. |
What to do first
LinkedIn — developer portal
LinkedIn splits this in two, and the two halves cannot share an app.
Posting as yourself is self-service. One app does it:
- Create an app and add the Sign in with LinkedIn product. It brings OpenID Connect and the member id with it.
- Copy the Client ID and the Client Secret from Auth.
- Register the redirect URI
https://<your-spora>/api/v1/plugins/social/oauth/linkedin/callbackunder Products → Sign in with LinkedIn → Redirect URLs. - Add the Share on LinkedIn product.
Member posting (w_member_social) is self-service: an app in
development mode posts as the member who signed in, and no review is
involved. This is as far as an individual developer goes. The Share on
LinkedIn product grants w_member_social and nothing else — it posts as
the signed-in member, never as a page.
Posting to a company page is a second app, and a different kind of
applicant. w_organization_social — the permission that posts on behalf of
a page — comes only with the Community Management API, and LinkedIn's
rules for that product are strict:
- It has to be the only product on the app. "Share on LinkedIn" is one
of the products that blocks it, so the app above can never hold it. The
Community Management API app is a second app, created with no
products at all and verified against the same company page.
"The Community Management API requires that it be the only product associated with a developer application due to legal and security constraints." — LinkedIn API Partner Support
- Only a legal organisation can ask for it. LinkedIn vets the
applicant: a verified business email, the organisation's legal name and
registered address, its website and a privacy policy, and a company page
whose super admin verified the app. "Personal email addresses won't pass
the vetting process." An individual developer without a registered legal
entity cannot get it.
"our Community Management APIs are only available to registered legal organizations for commercial use cases only" — Community Management app review
- It is a review in two tiers. Development tier first — 500 calls a day for the app, 100 a day per member — then Standard tier, which needs a screen recording of the app working.
Point the plugin at the Community Management API app. It grants
w_member_social as well, so that one app covers member posting and page
posting together. It cannot serve OpenID Connect, so turn Ask LinkedIn for
the member's identity off: the member is then named by the organisation
access-control list, which says who the role belongs to. That list is read
with r_organization_admin, so Ask LinkedIn for the administered
organisations has to be on as well — without it there is no list to read,
and the connect is refused.
The four read permissions are asked for one at a time — turn each on once the app holds it, never before, because an app that asks for a permission it does not hold is refused the whole authorization request. They are one setting each, so an app that holds one of them is not refused over the other three:
| Permission | Reads |
|---|---|
r_organization_admin |
the organisations the member administers |
rw_organization_admin |
an organisation's share statistics |
r_member_social_feed |
a member post's reactions |
r_organization_social_feed |
an organisation post's reactions |
All four are off by default. Without them the plugin still publishes; the
numbers simply stay empty. As long as Ask LinkedIn for the member's
identity is on, that is the whole of it. With it off, the member is named
by the access-control list instead, and r_organization_admin is the
permission that reads it — without it the connect is refused.
Meta — app dashboard
- Create a Business app. A Consumer app cannot use Instagram. The app must be owned by a business portfolio you control.
- Copy the App ID and the App Secret.
- Add the Facebook Login for Business product.
- Add the Manage messaging & content on Instagram use case. It is what brings up the API setup with Unified login tab, and that tab is where the configuration and the permissions live.
- Open Facebook Login for Business → Configurations and create one:
the system-user access token type, the Instagram professional
account and the Facebook Page as the assets, and the permissions
below. Copy the Configuration ID into
SPORA_SOCIAL_META_CONFIG_ID. - Register the redirect URL
https://<your-spora>/api/v1/plugins/social/oauth/meta/callbackunder the configuration. - Request the permissions:
instagram_basic,instagram_content_publishandinstagram_manage_insightsfor Instagram,pages_show_list,pages_manage_posts,pages_read_engagementandread_insightsfor the Pages. - To list the Pages — the client shares them, and the plugin needs
their ids — add
business_managementto the configuration. It grants the whole business portfolio and needs its own review, so the plugin does not ask for it: without it the connection covers the Instagram account alone, and the Pages have to be published to by their ids.
Standard Access against Advanced Access is the review state of the permissions, not of the app. Standard Access is enough to use the app with people who already have a role on it — the app's own testers and administrators — which is what you need to try the plugin with your own accounts. Advanced Access is what makes the permissions available to the public, and it needs an App Review, which takes weeks. So: start on Standard Access, get the live check green with your own accounts, and start the review before you write the copy. Nothing in the plugin waits for the review — it runs against a developer-mode app — but a post that reaches an audience of strangers needs it.
A public image for Instagram. Instagram fetches the media from the
internet, so the image has to be served from an address the platform can
reach, and it has to be a JPEG. Put one on any public host and set
SPORA_LIVE_PUBLIC_IMAGE_URL to it.
What the plugin does not set up for you
The Community Management API app is the operator's work: the second app, the legal entity, the access-request form, the screen recording for Standard tier. The plugin points at one LinkedIn app and asks it for the scopes the operator says it holds — it does not build the app, and it cannot get one approved.
That is deliberate, and it is also the reason a project without a legal entity cannot reach a page at all. Posting as a page is worth the path when there is an organisation behind it. An open-source project that wants a page of its own needs a registered legal entity to ask with, and that is the part that has to be arranged before any of this works.
What it leaves behind
The LinkedIn post is deleted again, and the Facebook post too where the platform allows it. The Instagram post stays — the API cannot delete a published post, so the live check leaves one post on your Instagram account. The run says so in its report.
The tools
Five tools, for agents that write the posts:
| Tool | Operations |
|---|---|
social_connections |
list, connect, disconnect, refresh, health, sync |
social_post |
create, schedule, publish, list, inspect, update, retry, delete |
social_media |
list_assets, check_fit, prepare, attach, detach |
social_analytics |
post_metrics, period_totals, top_posts, network_comparison |
social_calendar |
list_month |
schedule is the one call that schedules a post — it creates it and dates
it, so there is no separate create first. social_post with action
list takes a from / to window, which is how an agent sees what is
already going out.
A draft may be written without an account: the accounts are what name the platforms, and a draft has not been sent anywhere. It cannot go out until one is picked, and the plugin says so — on the answer, on the post in the list, and on the refusal to publish it.
Every read answers with the rows, not with a count: the account ids a post can go to, the asset ids that can be attached, the post ids and the days they are due. A model that cannot see an id has to guess it, and a guessed account id is a post that goes nowhere.
social_media with action check_fit judges an asset as the platform
receives it: the backend converts a PNG to a JPEG for Instagram
before it publishes, so a PNG fits Instagram and the answer says so.
Publishing needs the operator's approval: it cannot be undone on the network, and the copy goes out under the account's name. A scheduled post needs it too — it commits the copy to go out later without asking again.
What each platform accepts
The table below is the direct route's — the plugin's own networks. The aggregator's limits are Zernio's, and they differ for the same network.
| Platform | Media | Types | Cap |
|---|---|---|---|
| optional | JPEG, PNG, GIF | 8 MB | |
| optional | JPEG, PNG, GIF, BMP, MP4 | 1 GB | |
| required | JPEG (images), MP4 (video) | 100 MB |
An attachment that does not fit is flagged on the attachment, with the remedy named — "Convert to JPEG", "Compress" — because the operator can act on that and not on a platform's error text. The flag is for a file the plugin cannot fix: a file it can convert is converted first, and the flag does not appear.
A post carries one attachment, and the backend refuses a second one: the publish sends one file, so a second would be a file the operator believes is going out and is not. Instagram fetches the media from the internet, so the bytes have to be reachable from outside — the plugin serves them from a public URL for it; LinkedIn and Facebook take the bytes.
The aggregator's route carries the same rule. Zernio's own limit for Telegram is an album of ten images or videos, and Zernio takes several images and a video on LinkedIn — but the plugin's rule of one attachment per post still holds on every channel, and the publish refuses a second file. The aggregator's limits are the ones Zernio enforces on top of that; the plugin's own rule is the one it enforces itself.
A file the network will not take is converted, not refused. The archive holds a producer that re-encodes an image, so a WebP on its way to LinkedIn becomes a JPEG on the way there — the first type the network accepts that the archive can make, and the original stays in the library untouched. The fit check judges the converted file, so the operator is not told to convert a file the plugin has already converted for them.
The aggregator's own channels are the exception: one post goes to the aggregator as one file for a group of networks, so a file converted for one of them would not fit the others. The operator's file has to satisfy all of them, and the fit check says which one it does not.
A file the archive cannot convert — an SVG, a video where a network wants an image — is left as it is, and the fit check says the truth about it.
A post carries one body, and every platform publishes it. A platform
gets a body of its own only where its readers, its conventions or its limit
ask for one, and that copy goes in platform_bodies.
The body has a limit too, and the plugin enforces it at publish time:
LinkedIn 3,000 · Facebook 63,206 · Instagram 2,200 characters. Each target
is judged against its own platform's limit and its own body
(platform_bodies[platform], or the shared body), so a post aimed at
Instagram and Facebook with one body is checked twice. The refusal reads
the way the composer reads: "Over the limit: 5000 / 2200 characters for
Instagram."
Each platform shows part of the body before it folds the rest away: LinkedIn 200 characters before "see more", Facebook 125 in the feed, Instagram 125 before "more". The whole body is published either way.
Hashtags are a list of tags without the hashes. The plugin adds them under the body when the post goes out, on every platform.
What the aggregator accepts
Zernio's own limits, one row per network it publishes on. The plugin's table is a copy of Zernio's documentation, so it goes stale when Zernio changes a limit — the publish-time refusal is Zernio's own error, and the target carries it.
| Network | Media | Cap | Body |
|---|---|---|---|
| X | optional | JPEG, PNG, WebP or GIF up to 5 MB, or an MP4 or MOV video up to 512 MB | 280 characters free, 25,000 on Premium — a URL counts as 23 and an emoji as 2 |
| required | JPEG or PNG up to 8 MB, or an MP4 or MOV video up to 300 MB | up to 2,200 characters | |
| optional | JPEG, PNG or GIF up to 4 MB, or an MP4 or MOV video up to 4 GB | up to 63,206 characters, ~480 shown before "See more" | |
| optional | JPEG, PNG or GIF up to 8 MB, or an MP4, MOV or AVI video up to 5 GB | up to 3,000 characters | |
| TikTok | required | JPEG, PNG or WebP up to 20 MB, or an MP4, MOV or WebM video up to 4 GB | up to 2,200 characters |
| YouTube | required | an MP4, MOV, AVI, WMV, FLV, 3GP or WebM video up to 256 GB | up to 5,000 characters |
| optional | JPEG, PNG, WebP or GIF up to 32 MB, or an MP4 or MOV video up to 2 GB | up to 800 characters | |
| optional | JPEG, PNG or GIF up to 20 MB, or an MP4 video up to 1 GB | up to 40,000 characters | |
| Bluesky | optional | JPEG, PNG, WebP or GIF up to 1 MB, or an MP4 video up to 50 MB | up to 300 characters |
| Threads | optional | JPEG or PNG up to 8 MB, or an MP4 video up to 1 GB | up to 500 characters |
| Google Business | optional | JPEG or PNG up to 5 MB, no video | up to 1,500 characters |
| Telegram | optional | JPEG, PNG, GIF or WebP up to 10 MB, or an MP4 or MOV video up to 50 MB — Zernio puts up to ten of them in an album | up to 4,096 characters |
X counts its characters its own way — a URL is 23 characters whatever its length and an emoji is 2 — so the plugin does not count it for you: keep an X body short and let X refuse it if it is too long.
The aggregator fetches the media itself, from the URL the plugin sends, so the bytes have to be reachable from the internet — the plugin serves them from a public URL for it. The aggregator takes the original asset's bytes, not a converted derivative: it accepts JPEG, PNG, GIF and WebP, and it compresses above a network's limit itself.
Times and timezones
A post's scheduled_for is read in the post's own timezone:
- a value that carries an offset (or a
Z) is absolute — the named zone does not move it; - a value without one is a wall-clock time in the named zone, so
"2026-11-02 09:00"withtimezone: Europe/Berlingoes out at 08:00 UTC.
A timezone PHP does not know falls back to UTC rather than failing the
post.
What the numbers mean
- A LinkedIn member post has no impressions.
organizationalEntityShareStatisticsexists for organisation pages only; a member post reports its reactions alone. The plugin says so instead of showing a zero, which reads as "nobody saw it". - The account says what it cannot report, and why. The grid names the
reason on the account itself — "Member posts report reactions only — no
impressions", "Personal accounts have no media insights" — and, when
the reason is a permission the token does not carry, it names that
permission too. The permissions are the connection's, so an Instagram
account whose token does not carry
instagram_manage_insightssays so. A token that never reported its scopes is not treated as one without them. - Reach counts people, impressions count views. LinkedIn's reach is
uniqueImpressionsCount, Facebook's ispost_impressions_uniqueand Instagram's isreach— the same question, asked of each network. - The token's health sits on the connection. One login, one token: the
expiry, the status (
active,expired,revoked,error) and the needs reconnect flag belong to the connection, not to each account it covers. - A Meta token is not renewed, it is reconnected. It has no refresh grant: the refresh is for a token with an expiry, and a Meta token "defaults to never expire" — the configuration decides, and the expiry the platform sends is the expiry the connection carries. A Meta token that has stopped working is reconnected, not refreshed.
- Every reading is a dated snapshot. Impressions keep climbing for days and the platforms restate them. Nothing here is final.
- The networks are kept apart. They count reach differently, so the rows stay separate and the combined figure is labelled a rough one. A row is a channel — the network and the route to it — so the same network on two routes is two rows.
- An aggregator's numbers are an add-on on its plan. Without it the target reports no numbers, and the note says why. The follower count needs the same add-on, and the accounts screen says whether the plan has it. And one aggregate for the whole aggregator post covers every network in it, not the one account — so the target says so too.
Install
composer require spora-ai/spora-plugin-social
The frontend is a separate Composer package
(spora-ai/spora-plugin-social-frontend) and is pulled in with it.
The frontend package is built in parallel with this one. Until it is published,
composer installneeds a local stand-in for it — a Composer package of typespora-plugin-frontendwithextra.spora-plugin-slug: social— registered as apathrepository.composer.lockthen pins that path and has to be regenerated once the real package is on Packagist:composer update spora-ai/spora-plugin-social-frontend.
What the plugin needs from core
composer.json requires spora-ai/spora-core >=0.32.0 <2.0.0.
Two core extension points carry this plugin, and neither exists below 0.32:
Spora\Scheduling\DueWorkProviderInterface— the plugin's own work (a scheduled post whose moment has come, a reading that is due) is claimed and executed by the worker through this hook. Without it a post can be published by hand but nothing fires on its own.Spora\Settings\PluginSettingsProviderInterface— the client ids and secrets below are operator-facing settings, and core's registry is what gives them a key, a storage group and a place on the Plugins tab.
The core release carrying them must be tagged before this plugin ships.
compat/core-extension-points.php declares the two contracts so the
analysis and the suite can run against a core that predates them; it
declares nothing once core ships the real classes, and it is not a
substitute for the release.
Development
composer analyse # PHPStan composer test:parallel # Pest composer live # the live check against the real platforms composer format # php-cs-fixer
Connecting an account on a local install
SPORA_APP_URL has to be an address the internet can reach, because
Instagram fetches the published media with Meta's own servers. A
localhost address cannot be — so the connect is refused before the
platform is even involved.
A development install can say that it knows that:
| Setting | Value |
|---|---|
SPORA_SOCIAL_ALLOW_LOCAL_APP_URL |
true — also a toggle in Settings → Plugins |
What it buys. The connect goes through, so there is a token, a draft, a schedule and a publish to develop against. LinkedIn and Facebook photos upload the bytes and work either way.
What it does not buy. Instagram still fails: the platform fetches the
media from {SPORA_APP_URL}/api/v1/public/media/{id}?token=… with its own
servers, and no setting makes a loopback address reachable from Meta.
Those publishes fail with the platform's own reason, recorded on the
target — which is the failure worth seeing while developing.
For a local install that publishes to Instagram, put a tunnel in front of it instead:
cloudflared tunnel --url http://127.0.0.1:8080 # → https://<host>.trycloudflare.com
and set SPORA_APP_URL to that address. It is the address the OAuth
redirect URI is built from and the address the platform fetches the
media from, so it has to be the one registered with the app — the address
of your API, not the one the request happened to carry. Core never reads
X-Forwarded-*, and the tunnel ends TLS locally, so the detected origin
would be http://<host> rather than https://<host>: set it explicitly.
Set SPORA_SOCIAL_FRONTEND_URL to the origin the panel runs on when that
is not the API's own, so the browser lands back on the panel after the
OAuth round trip.
Leave the setting off in production. A production install that carries it publishes to Instagram and fails with a platform error that says nothing about the address.
The local checkouts
The two dependencies this package needs — spora-ai/spora-core and
spora-ai/spora-plugin-social-frontend — are resolved from Packagist, so
the repository carries no machine-specific path. To work against the local
checkouts, add them as path repositories on your machine:
composer config --global repositories.spora-core-local '{"type":"path","url":"../spora-core","options":{"symlink":true,"versions":{"spora-ai/spora-core":"0.32.0"}}}' composer config --global repositories.spora-frontend-local '{"type":"path","url":"../spora-plugin-social-frontend","options":{"symlink":true,"versions":{"spora-ai/spora-plugin-social-frontend":"0.1.0"}}}'
composer.lock is not committed until both dependencies are published.
A lock generated now could only pin a local checkout or an untagged branch,
and it would be committed by accident on the next machine that ran
composer install. It is committed at release, like every other package in
the house.
The documentation for this plugin lives at docs.spora-ai.com/develop/plugins/reference/social; the live-check runbook above is the operator-facing half of it.
Licence
MIT.