User Manual · 06 · Managing Clients
WholeReach — Automated Marketing System

Managing Clients — Selling It as a Service

How the same engine manages other people's social media: multi-tenant setup, onboarding, the approval portal, pricing, and white-label.

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.

FieldWhat it holdsWhat it does
nameA human label for the tenant.Identifies the client in the queue, digests, and logs. This is what your team sees when triaging.
brandAn object: brand_name and tagline.The voice the posts speak in. Templates weave the brand name and tagline into finished copy so a builder's posts sound like the builder, not like the network.
cadenceA number — how many posts are kept in flight.Sets the posting rhythm. Roughly 7 ≈ one post a day. Raise it for a client who wants more presence; lower it for a light touch. The generator tops the queue back up to this number each night.
channelsA list, e.g. ["x","bluesky"].Where this client's posts publish. Only the accounts named here can ever receive their posts. The special value "dry" logs the post without sending it anywhere — a safe rehearsal mode.
require_approvaltrue or false.true routes every post to the client's approval queue — nothing publishes until a human taps Approve. false lets posts auto-fire on schedule. Most new clients start at true.
template_moduleThe Python module that writes this tenant's copy.Chooses the content family. The owner's network uses the self-promo templates (portal_templates_wt.py); a builder client uses the builder-demo templates (portal_templates.py). This is how one client's posts differ in kind from another's.
listingsA path to the tenant's verified-facts file, e.g. listings/acme.json.The only source of truth the templates may draw from for this client. Every fact in a post comes from here. No listing fact, no post — the honesty guard holds it.

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 exact commands live below. This page focuses on the business and the model. For the precise, copy-paste onboarding sequence — adding the block, seeding the listing, generating a dry run, connecting accounts, and cutting over — see the step-by-step onboarding runbook below on this page, and the connect and approve procedures in the Playbooks (connect an account, approve posts).

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."
Creating the link. The exact procedure for minting and sending a client's approval link is in the onboarding runbook below; the everyday approve/edit/skip flow is Playbook: approve posts.

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.
TierIndicative / monthWhat's included
Starter~$99One free channel (Bluesky), light cadence, approval queue on, monthly digest. A low-risk way for a client to see the machine work on their own brand.
Managed~$299Multiple channels including X (within the daily cap), a fuller cadence, approval portal with magic link, and a regular results digest. The everyday managed-service tier.
Specialist~$599+Custom template family in the client's exact voice, higher cadence, more channels as they're connected, priority attention, and white-label reporting. For clients who want the presence to look bespoke.
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.

$24/mo
hub, all clients
$0
to generate any post
1 block
to add a client
1 file
pauses everyone
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

  1. Discovery. Agree what they want posted, how often, on which networks, and whether they want to approve each post or run hands-off.
  2. 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.
  3. 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
  4. 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"
    }
  5. 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.
  6. 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.
  7. 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.

In plain EnglishEach client gets their own private, expiring link. You generate it once and send it to them.

Do it — step by step

  1. 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))\""
  2. It prints the full link. Send it to the client (or open it yourself to approve for them).
  3. 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.