The same engine that runs the owner's own network can run other people's social media — as a paid, managed service, with each client walled off in their own accounts, cadence, and content.
Yes — this manages other people's social media today. The system is built multi-tenant from the ground up. A "tenant" is a client: one config block gives them their own brand voice, posting rhythm, connected accounts, content templates, and an optional human approval step. The owner's network is just one tenant among many. Adding a paying client doesn't require new software — it's a config entry, a set of connected accounts, and a magic link. That's the whole business: sell a managed social-media service whose delivery cost barely moves as you add clients.
The multi-tenant model, field by field
Every tenant is one JSON block in /opt/portal/clients.json on the hub. The block is the entire definition of a client — there is no hidden per-client code. Read the block and you know exactly what that client gets: who they are, how often they post, where their posts go, whether a human signs off first, which template family writes their content, and where the verified facts come from.
Here is what each field does. Nothing here is optional magic — every field maps to a concrete behavior in the nightly run.
A realistic tenant block
Here is what a new client — a fictional home builder, "Acme Builders" — looks like the moment they're onboarded in dry mode, before any real account is connected:
"acme": {
"name": "Acme Builders",
"brand": {
"brand_name": "Acme Builders",
"tagline": "Custom homes, built to last"
},
"cadence": 7, # about one post per day
"channels": ["dry"], # rehearsal only — logs, never sends
"require_approval": true, # client signs off on every post
"template_module": "portal_templates",
"listings": "listings/acme.json"
}
The golden rule. A new tenant starts on the "dry" channel — and a test tenant never points at real channels. Dry mode drafts and logs real posts so you can review the wording and cadence, but sends nothing. Only when the client has seen a few days of dry posts and approved the voice do you connect their real accounts and switch channels to, say, ["x","bluesky"]. Pointing a test tenant at a live account is how you accidentally post rehearsal copy to a real audience.
Account isolation & safety
The whole service rests on one guarantee: one client can never post to another client's accounts. This isn't a policy or a promise to be careful — it's how the system is wired.
Each tenant's channels list names only that tenant's own connected accounts. The publisher reads a post, looks up the tenant that owns it, and can only reach the accounts in that tenant's block. There is no shared "post everywhere" path. Acme's posts physically cannot be sent to the owner's network accounts, and vice versa.
The access tokens for connected accounts live in the hub's database (the Integration table), keyed to the organization that connected them — not in code, and not shared between tenants. Each tenant's verified facts live in their own listings/<tenant>.json file, so one client's content pool is separate from another's. A post about Acme's homes is written only from Acme's listing.
Why this matters for selling. When a client asks "how do I know you won't cross-post my stuff, or leak my accounts to another customer?" the honest answer is structural: their accounts, their tokens, their facts, and their approval queue are all separate objects tied to their tenant slug. Isolation is the default, not a feature you have to remember to switch on.
Client onboarding — the shape of it
Onboarding a client is a short, repeatable sequence, and it's fast — the first one takes an afternoon, the tenth takes minutes. At a high level:
1 · define
Add the tenant block. Name, brand, cadence, template module, and a listings path — started on the "dry" channel with approval on.
↓
2 · facts
Fill the listing. Drop the client's verified facts into listings/<slug>.json — the only well the templates draw from.
↓
3 · rehearse
Generate in dry mode. Run the generator, review the drafted posts, confirm the voice and cadence look right — nothing has been sent yet.
↓
4 · connect
Connect real accounts in the hub, then switch channels off "dry" to the live networks.
↓
5 · hand over
Mint the client's magic link to their approval queue and send it. They're live.
The approval portal — the client's experience
This is the part clients actually touch, and it's what sells the service. Each tenant gets a private web page — a queue of pending posts they can approve on their phone in the time it takes to drink a coffee.
What the client sees
You send them a single link. They tap it — no password, no app to install — and land on a clean, phone-friendly queue of the posts the system has drafted for them. For each post they get three choices:
- Approve — the post publishes on its scheduled slot.
- Edit — they tweak the wording, then approve their version.
- Skip — the post is dropped and never publishes.
That's the entire experience. No dashboards to learn, no calendar to manage. A busy owner can clear a week of posts at a red light.
Under the hood
The link is a magic link: an HMAC-signed, expiring token tied to the tenant's slug, of the shape https://postiz.wholetech.com/portal/app/?c=<slug>&t=<token>. It's minted with portal_db.make_token(slug, days), so you control how long it stays valid. The queue and the Approve/Edit/Skip actions are served by the approval API (portal.service on 127.0.0.1:8990) — GET /portal-api/queue to list, POST /portal-api/act to approve, edit, or skip.
Why it closes the sale. Most "AI social" tools either flood an account with unreviewed slop or bury the owner in a dashboard they'll never open. This is the middle path clients actually want: the machine does the work, and the human keeps a light, one-tap hand on the wheel. It's the difference between "we'll post for you" and "we'll post for you, and you'll always know what's going out."
Brand safety for client accounts
Running someone else's brand is a trust business. Four things, working together, make it safe to do at scale.
The honesty guard
Every template declares the facts it needs. If a required fact is missing from the client's listing, the post is held — shown, flagged, and never published — instead of being filled with a guess. The system will never state something about a client's business it can't verify from that client's source data. This is the single most important brand-safety feature: a client's account cannot post a hallucinated claim, because there is no path that invents facts.
Approval mode
Set require_approval: true and nothing reaches the client's account without a human tap. New clients start here by default. As trust builds, a client can move to auto-fire — but the choice is theirs, per tenant.
The kill switch
One file freezes everything. touch /opt/portal/PAUSE and the publisher sends nothing — for every tenant — until you rm it. If anything ever looks wrong, you stop the whole world in one command and sort it out calmly. See Playbook: stop everything.
Isolation
As covered above, a client's accounts, tokens, facts, and queue are separate objects. A mistake in one tenant's config can't spill into another's account.
Honesty guard + approval + kill switch + isolation. Any one of these is a decent safeguard. Together they're the reason it's genuinely responsible to hand a paying client's social presence to an automated system — it won't lie, a human can gate it, you can stop it instantly, and it's walled off from everyone else's accounts.
Pricing the service
The unusual economics here are the reason the service is worth building. Content generation is free — the templates are deterministic, so writing 1,000 posts costs exactly what writing one does: nothing. There is no per-token AI billing. The only channel that costs anything per post is X, at roughly a cent and a half a post (about twenty cents with a link), and Bluesky is free and uncapped. The infrastructure is a flat ~$24/month for the hub, shared across every client you run on it.
So the marginal cost of adding a client is close to zero, and the marginal cost of an extra post is pennies at most. That means almost the entire subscription price a client pays is margin.
These are indicative estimates, not a rate card. The tiers below are a framing to help you think about packaging — set your own numbers for your market. What's fixed is the shape: strong margins because delivery cost barely moves with volume.
Where the margin comes from. If a Managed client pays ~$299 and their delivery cost is a slice of a $24 hub plus a few cents a day of X posting under the cap, the gross margin is enormous — and it improves as you add clients, because they share the same box and the same free generation. This is flat-subscription economics: cost doesn't climb with scale the way it does for tools that bill per generated post.
White-label — serving under a partner's brand
You don't have to sell this under your own name. A partner — an agency, a marketing consultant, a builder association — can resell the service to their own clients, with the portal and reports carrying their brand.
Two things make white-labeling natural. First, the approval portal a client sees is already generic and phone-first — it's the queue and the three buttons, not a branded destination the client has to trust. Second, each tenant's brand block already sets the voice of the posts to the end client's brand, so the content never reads as coming from you. A partner can present the whole thing — the drafted posts, the approval link, the digest — as their own managed offering.
Under this arrangement you're the operator running the engine; the partner owns the client relationship and sets their own price on top. Your cost per partner-client is the same near-zero marginal cost as any other tenant, so a healthy wholesale price to the partner still leaves them room to mark it up.
Reporting under a partner's brand is part of the Specialist tier framing above — the digest can be presented as the partner's own results report to their client.
Reporting to clients
A managed service that never shows results doesn't renew. The system writes a digest every night — report.py runs at 05:20 UTC — so there's always a current picture of what went out and what's queued.
The digest is the raw material for what you send a client: which posts published, on which channels, what's approved and waiting, and anything held by the honesty guard because a fact was missing. That last one is quietly valuable — a held post is a prompt to go fill in a gap in the client's listing, which makes the next batch of posts richer.
Because the whole pipeline closes the loop — it publishes and records — the report is grounded in what actually happened, not in what was merely drafted. That's the honest version of a "results" email: here is what we posted for you this week, here is what's coming, and here is nothing we made up.
Where it comes from. The digest is generated on the hub (log at
/var/log/portal-rep.log). For the deeper picture of what the reporting draws on and how the nightly chain fits together, see
How It Works and
Daily Operations.
Scaling to many clients
The honest question a prospective operator asks is: "what actually changes when I go from two clients to twenty?" Less than you'd think.
What scales for free: content generation (deterministic templates cost nothing to run more of), the hub (one box, one flat plan, shared by all tenants), and the honesty guard, isolation, and approval machinery (already per-tenant — they don't need re-engineering for each new client).
What grows linearly, but cheaply: the per-post cost on X, capped per day by X_DAILY_CAP so a runaway bill isn't possible; and the operator's triage time — someone still glances at held posts and helps clients who lean on you for approvals. That's the real human load, and it's small: reviewing held posts and topping up listings, not writing content.
What you add per client: a tenant block, a listing file, a set of connected accounts, and a magic link. No new code, no new server, no new subscription.
The one thing to watch as you scale is the X spend across all tenants — every client posting to X shares the paid channel. The per-day cap protects you, and Bluesky (free, uncapped) carries the volume. Keep an eye on
X spend and let the free channels do the heavy lifting.
Your first client, in an afternoon
Here's how it all ties together, told as a single sitting.
You've met a home builder — call them Acme — who's tired of their neglected social accounts. You open clients.json and add an acme block: their brand name and tagline, a cadence of 7 (about a post a day), the builder-demo template module, approval on, and — crucially — the "dry" channel. You drop their verified facts (their communities, their finishes, their warranty, whatever is true and worth saying) into listings/acme.json.
You run the generator. Within a minute the queue fills with a week of finished posts written in Acme's voice, drawing only on Acme's facts. Nothing has been sent — dry mode logged them for you to read. A couple look thin; you notice the honesty guard held one because a fact was missing, so you add that fact to the listing and regenerate. Now the batch reads well.
You show Acme the drafts. They love that it's their voice and their real projects, not generic filler. You connect their X and Bluesky accounts in the hub, switch their channels from "dry" to ["x","bluesky"], and mint them a magic link to their approval queue. You text them the link.
That evening Acme taps through the week's posts on their phone — Approve, Approve, Edit, Approve — and the first ones start publishing on schedule. You've onboarded a paying client, in their own brand, safely gated, for a marginal cost of pennies. The tenth client will take you ten minutes.
That's the business. A free-to-generate content engine that won't lie, a one-tap approval portal clients actually use, hard isolation between accounts, a flat-cost hub, and a kill switch on every tenant. You sell a managed social-media service; the machine delivers it; your cost barely moves as you grow. Start with the
onboarding runbook below.
Client onboarding — step by step
Add a new client, start to finish
In plain EnglishA client is one entry in a config file plus a file of their verified facts. This walkthrough takes a fictional client “Acme Builders” from nothing to live, safely — a dry run first, then real posting.
Do it — step by step
- Discovery. Agree what they want posted, how often, on which networks, and whether they want to approve each post or run hands-off.
- Connect their accounts. Have them authorize their networks through the hub — follow Playbook 1 once per network. Their tokens are stored under their own account.
- Create their facts file on the hub — the verified things their posts can draw from (products, listings, services, wins). Model it on an existing one:
ssh root@64.227.29.192 nano /opt/portal/listings/acme-builders.json
- Add their tenant block to the config:
ssh root@64.227.29.192 nano /opt/portal/clients.json
Add an entry like:"acme-builders": {
"name": "Acme Builders",
"brand": { "brand_name": "Acme", "tagline": "Built for life." },
"cadence": 5,
"channels": ["dry"], # dry first — see step 5
"require_approval": true,
"template_module": "portal_templates_wt",
"listings": "/opt/portal/listings/acme-builders.json"
}
- Dry run. With channels set to
["dry"], generate and review the drafts — nothing is sent anywhere:ssh root@64.227.29.192 python3 /opt/portal/generate.py
Then read them with Playbook 9.
- Go live. When the drafts look right, change
"channels" to their real networks (e.g. ["bluesky","linkedin"]) and regenerate. With require_approval: true, posts now wait in their queue.
- Give them their approval link (next section) — or approve on their behalf.
Always dry-run first.The ["dry"] channel logs a post without sending it — the safe way to prove a new client’s output before anything reaches their real audience.
The approval portal & the client’s link
Clients who want a say get their own private, phone-friendly approval queue behind a secure, expiring link — no login. They tap Approve, Edit, or Skip on each drafted post; only approved posts publish. Held posts (missing a verified fact) show clearly and cannot be approved until resolved.
Create a client’s approval link
In plain EnglishEach client gets their own private, expiring link. You generate it once and send it to them.
Do it — step by step
- Generate a 30-day link for the client (use their tenant name):
ssh root@64.227.29.192 "python3 -c \"import sys;sys.path.insert(0,'/opt/portal');import portal_db as db;print('https://postiz.wholetech.com/portal/app/?c=acme-builders&t='+db.make_token('acme-builders',30))\""
- It prints the full link. Send it to the client (or open it yourself to approve for them).
- When it expires, run the command again for a fresh one.
Why it’s safeTokens are HMAC-signed with the server secret and carry an expiry — they can’t be forged or reused after they lapse.