Drop a file in a folder. Talk to the Fediverse.
SocialSecretary is a headless ActivityPub sidecar. It handles the Fediverse social handshake — followers, signatures, delivery — so that any publishing tool can participate in the Fediverse without understanding ActivityPub. Your publishing tool writes a JSON file into a directory. SocialSecretary does the rest.
This guide takes you from a fresh Ubuntu server to a live Fediverse
presence at @you@yourdomain, able to receive followers and
broadcast posts from any tool that can write a file.
It is a manual installation, on purpose. There is no install script. Every step is short, every step says why it exists, and when it is done you will know what is on your server and why. That knowledge is what lets you operate it afterwards, and it is what an install script would have taken from you.
Estimated time: one to two hours for a first installation, most of it reading. Assumed skills: you can connect to a server with SSH, edit a text file there, and copy a command from this page. Nothing else is assumed.
SocialSecretary is a small Node.js process that runs alongside your publishing setup. It does one thing: it handles the social layer of ActivityPub. It does not manage content, host a website, or provide a user interface. It is the administrative clerk, not the editor-in-chief.
The hard part of ActivityPub — HTTP signatures — is about 150 lines of careful code using only Node's built-in modules. The complexity of Mastodon and its relatives comes from everything else they are trying to be at the same time: platform, recommendation engine, moderation system, growth mechanism. Strip that away and what is left is signed JSON and a list of addresses. That is what SocialSecretary is.
What ships as code (you do not edit these):
| File | Role |
|---|---|
index.mjs |
HTTP server and router |
inbound.mjs |
Receives follows, likes, replies, and everything else the Fediverse sends you |
outbound.mjs |
Broadcasts your posts to your followers |
signatures.mjs |
Signs what you send; verifies what you receive |
followers.mjs |
Your follower list |
ledger.mjs |
The archive of every object you have sent — posts, updates, deletes, likes, boosts, undos |
watermark.mjs |
Remembers what has already been broadcast |
log.mjs |
Writes the activity log — everything sent and received, one JSON line per event |
config.mjs |
Reads your configuration and derives every path from it |
What you provide, from the annotated stubs in the
stubs/ directory:
| File | Role |
|---|---|
socialsecretary_config.mjs |
Your settings — the one file you fill in |
actor.json |
Your ActivityPub identity |
webfinger.json |
How the Fediverse finds you from your handle |
Caddyfile |
Routes traffic to the right place |
socialsecretary.service |
Keeps SocialSecretary running |
denylist.txt |
Optional: domains you refuse to hear from |
Every stub has its instructions written inside it as comments. Read them. They explain not just what to fill in but why each field exists and what goes wrong if you get it wrong. Someone who reads the stubs understands the whole system before running a single command.
This is the layout the rest of this guide builds, and the layout the operator guide assumes. Come back to it whenever a step's purpose is unclear.
/opt/socialsecretary/ SocialSecretary itself (owned by secretary)
index.mjs, inbound.mjs, ... the code
socialsecretary_config.mjs your settings
stubs/ the templates you copied from
keys/YOUR_DOMAIN.COM/YOUR_HANDLE/
private.pem signs everything you send — never leaves this server
public.pem published in actor.json so others can verify you
actors/YOUR_DOMAIN.COM/YOUR_HANDLE/ created automatically on first run
outbox/ your publishing tool writes envelopes here
processing/, failed/ in-flight and failed envelopes (see operator guide)
mailboxes/ what the Fediverse sends you, sorted by kind
archive/ every object you have ever published
index/ lookup tables derived from archive/ and mailboxes/
followers.json who follows you
last_broadcast.txt the watermark: what has been broadcast
retry.json deliveries waiting to be retried
log/ what Secretary sent and received — the third
contract; see the Activity Log Specification
denylist.txt optional blocklist
/opt/secretary/ home directory of the secretary user (empty)
/opt/sites/YOUR_DOMAIN.COM/public/ static files served by Caddy
actor.json your identity document
.well-known/webfinger discovery document
avatar.png your avatar
/etc/caddy/Caddyfile Caddy's routing configuration
/etc/systemd/system/socialsecretary.service
the service definition
Replace YOUR_DOMAIN.COM with your domain and
YOUR_HANDLE with the handle you want, everywhere they
appear. Your Fediverse identity will be
@YOUR_HANDLE@YOUR_DOMAIN.COM. Choose the handle now;
changing it later means starting over, because it is baked into your
identity document and every follower's cached copy of it.
Commands are shown with sudo where root is needed and
without it where it is not. That is deliberate — see Step 2 for why
SocialSecretary itself never runs as root.
A VPS running Ubuntu 22.04 or later. 1 GB of RAM is sufficient; SocialSecretary idles at around 50 MB. It has been run in production on the smallest droplet DigitalOcean sells.
An A record (and AAAA if you have IPv6) for
YOUR_DOMAIN.COM pointing at the server's address. It must
be resolving before Step 7, because Caddy obtains your TLS certificate
by proving to Let's Encrypt that it answers for that name. Check from
your own machine:
dig +short YOUR_DOMAIN.COM
It should print the server's IP address. If it prints nothing, wait for DNS to propagate before continuing.
Your handle lives at the domain's root:
@you@yourdomain.com, not
@you@social.yourdomain.com. A subdomain works technically
but reads as second-class in a way a root domain does not.
Three packages. Each is explained at the step that uses it.
# Node.js 20 — runs SocialSecretary
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# Caddy — reverse proxy and automatic HTTPS
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
# openssl — generates your keypair (usually already installed)
sudo apt install -y openssl
Verify:
node --version # v20 or later
caddy version
openssl version
There is no npm install anywhere in this guide.
SocialSecretary has no dependencies beyond Node itself, and the process
manager is systemd, which Ubuntu already has. Nothing gets installed
from npm.
SocialSecretary runs as its own user, secretary. It is
never run as root and never as your login user.
sudo useradd --system --home-dir /opt/secretary --create-home \
--shell /usr/sbin/nologin secretary
What each option is for:
--system — a system account: no password, no aging, a
low UID, and it does not appear on login screens. It exists to run one
program.--home-dir /opt/secretary --create-home — the user
needs a home directory even though nothing of SocialSecretary's lives
there. Node, openssl, and various tools expect $HOME to
exist and behave badly when it does not. Keeping it under
/opt rather than /home signals that it is not
a person.--shell /usr/sbin/nologin — nobody can log in as this
user, not even with a password, because there is none. If an attacker
ever obtains its credentials they get a program that refuses to open a
shell.Why a dedicated user at all: SocialSecretary holds your private key,
accepts requests from the entire Fediverse on /inbox, and
writes files to disk. If it is ever compromised, the damage is limited
to what secretary can touch — which, after the ownership
steps below, is /opt/socialsecretary and nothing else. Your
login user, your other services, and the rest of the filesystem are out
of reach. This is the single cheapest security measure in the whole
installation and it costs one command.
cd /tmp
curl -O https://socialsecretary.pub/dist/socialsecretary-latest.tar.gz
curl -O https://socialsecretary.pub/dist/socialsecretary-latest.tar.gz.sha256
curl -O https://socialsecretary.pub/dist/socialsecretary-latest.tar.gz.asc
Three files: the archive, its checksum, and its signature. Now verify it. This takes a minute and it is the only step in this guide that protects you from installing something other than what was published.
gpg --auto-key-locate clear,wkd --locate-keys rob@socialsecretary.pub
gpg --verify socialsecretary-latest.tar.gz.asc socialsecretary-latest.tar.gz
sha256sum -c socialsecretary-latest.tar.gz.sha256
What each line does, and what it proves:
gpg --locate-keys fetches the release
signing key from socialsecretary.pub itself, using Web Key
Directory — the same idea as the WebFinger lookup that will let people
find you in Step 6: ask the domain, and the domain answers. No
keyserver, no copying a key out of a web page. It prints a fingerprint.
It must be:
735C CBB0 C5C2 73DD E2C5 0FA1 A84B 1564 762B 3085
If it is anything else, stop. Either the domain has been compromised or you are not talking to it.
gpg --verify checks that the archive
was signed by that key. Want:
Good signature from "Rob <rob@socialsecretary.pub>".
You will also see a warning that the key is not certified with a trusted
signature — that is gpg noting that you have not personally vouched for
the key, which is true and fine; the fingerprint check above is your
vouching. A BAD signature means the archive is not what was
published. Delete it.
sha256sum -c confirms the download
arrived intact. Want: socialsecretary-latest.tar.gz: OK.
The signature already implies this; the checksum is the quick check you
can run without gpg at all.
Do not proceed past a failed check. Delete the files and download again; if it fails twice, something is wrong upstream and you should say so.
sudo mkdir -p /opt/socialsecretary
sudo tar -xzf socialsecretary-latest.tar.gz -C /opt/socialsecretary --strip-components=1
sudo chown -R secretary:secretary /opt/socialsecretary
ls /opt/socialsecretary
You should see the .mjs files from the table above, a
stubs/ directory, a docs/ directory, and a
tools/ directory. Everything under
/opt/socialsecretary is now owned by
secretary, which is the user that will read and write
it.
SocialSecretary creates its own state directories on first run. Two things it will not create, and you must:
# The static files Caddy serves: identity, discovery, avatar
sudo mkdir -p /opt/sites/YOUR_DOMAIN.COM/public/.well-known
# The directory that will hold your keypair
sudo mkdir -p /opt/socialsecretary/keys/YOUR_DOMAIN.COM/YOUR_HANDLE
sudo chown -R secretary:secretary /opt/socialsecretary/keys
The keys directory is left for you to create on purpose. Its absence on startup is a setup error SocialSecretary reports clearly — "private key not found at …" — rather than something it papers over by generating a key you did not know about. A key you did not knowingly create is a key you cannot knowingly protect.
/opt/sites/YOUR_DOMAIN.COM/public is owned by root and
read by Caddy. That is correct: nothing in it changes at runtime, and
SocialSecretary never writes to it. If you later run a website from the
same server, this is where its files go too.
cd /opt/socialsecretary/keys/YOUR_DOMAIN.COM/YOUR_HANDLE
sudo -u secretary openssl genrsa -out private.pem 2048
sudo -u secretary openssl rsa -in private.pem -pubout -out public.pem
sudo chmod 600 private.pem
sudo chmod 644 public.pem
ls -l
You should see private.pem readable only by
secretary and public.pem readable by everyone.
Both owned by secretary, because they were created as
secretary.
What the keypair is for: every request SocialSecretary sends to
another server — a post to a follower, a like, a follow acceptance — is
signed with private.pem. Every server that receives it
fetches your actor.json, reads public.pem from
it, and checks the signature. That is how the Fediverse knows a post
claiming to be from you is from you. There is no account, no password,
and no central authority; there is a key, and you hold it.
private.pem never leaves this directory. SocialSecretary
reads it at broadcast time. Your publishing tool never touches it and
never needs to. Back it up somewhere safe: if it is lost, your identity
is lost, and every follower would have to follow you again under a new
key.
RSA 2048 is the size the Fediverse expects. Larger keys work but buy nothing here; some older implementations reject them.
Two small JSON files tell the Fediverse who you are. Both are static — served by Caddy, never touched by SocialSecretary.
sudo cp /opt/socialsecretary/stubs/actor.json \
/opt/sites/YOUR_DOMAIN.COM/public/actor.json
sudo cp /opt/socialsecretary/stubs/webfinger.json \
/opt/sites/YOUR_DOMAIN.COM/public/.well-known/webfinger
Note the second file is renamed: webfinger, no
extension. That is the path the WebFinger standard requires.
Now open each and fill it in. The stubs end with a block of comments explaining every field. Read them, fill in the fields, then delete the comment block — JSON does not permit comments, and a file that still has them will not parse.
actor.json — the fields that must be
right:
id: https://YOUR_DOMAIN.COM/actor.json,
exactly, with no trailing slash.preferredUsername: YOUR_HANDLE. Must match
actor in Step 8's config.published: today's date,
2026-09-05T00:00:00Z. Set once, never change it.publicKeyPem: your public key as a single JSON string,
with the line breaks written as \n. This command prints it
in the right form:awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' \
/opt/socialsecretary/keys/YOUR_DOMAIN.COM/YOUR_HANDLE/public.pem
Paste the entire output — beginning with
-----BEGIN PUBLIC KEY-----\n — as the value of
publicKeyPem, inside quotes.
icon: leave the block out for now; Step 12 adds it. Do
not leave a placeholder URL — a broken avatar URL looks worse than
none.webfinger has two placeholders, your
handle and your domain. The subject line must read
acct:YOUR_HANDLE@YOUR_DOMAIN.COM; this string is compared
byte-for-byte against what someone types into a Mastodon search box.
Verify both files are valid JSON before going on:
node -e "JSON.parse(require('fs').readFileSync('/opt/sites/YOUR_DOMAIN.COM/public/actor.json','utf8')); console.log('actor.json OK')"
node -e "JSON.parse(require('fs').readFileSync('/opt/sites/YOUR_DOMAIN.COM/public/.well-known/webfinger','utf8')); console.log('webfinger OK')"
A parse error here almost always means the comment block was not
deleted, or a \n in the key was typed as a real line
break.
Caddy is the reverse proxy: it answers HTTPS on your domain and routes each request to the right place — SocialSecretary for the ActivityPub endpoints, static files for identity, and later your website for everything else. It also obtains and renews your TLS certificate automatically; you will never run a certificate command.
sudo cp /opt/socialsecretary/stubs/Caddyfile /etc/caddy/Caddyfile
sudo nano /etc/caddy/Caddyfile
Replace YOUR_DOMAIN.COM everywhere it appears. The
stub's comments explain each block; the summary:
| Path | Goes to | Why |
|---|---|---|
/inbox |
SocialSecretary | The one endpoint the Fediverse sends to. The only writable surface on your domain. |
/trigger |
SocialSecretary | Your publishing tool pings this after writing an envelope |
/outbox* |
SocialSecretary | Your post history; Mastodon reads it for your profile |
/followers* |
SocialSecretary | Your follower list, for follower counts |
/objects/* |
SocialSecretary | Individual published objects |
/activities/* |
SocialSecretary | Individual activities. Mastodon fetches these to verify a post is authentic; without this route your posts may be silently dropped. |
/actor.json |
static file | Your identity |
/.well-known/webfinger |
static file | Discovery |
/avatar.png |
static file | Added in Step 12 |
| everything else | placeholder | Replace with your website when you have one |
Order matters. Caddy tries handle blocks top to bottom
and the last one matches everything, so every specific route must appear
above it. If you later add a static file and it comes back as
text/plain or as the placeholder text, this is why — it
needs its own handle block above the catch-all.
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
validate catches syntax errors before they take your
site down. reload applies the file without dropping
connections. Now confirm the identity files are reachable from the
outside — run this from your own machine, not the server:
curl -sI https://YOUR_DOMAIN.COM/actor.json | grep -i content-type
curl -s "https://YOUR_DOMAIN.COM/.well-known/webfinger?resource=acct:YOUR_HANDLE@YOUR_DOMAIN.COM"
The first should say application/activity+json. The
second should print your webfinger file. If either fails, Caddy's log is
the place to look: sudo journalctl -u caddy -n 50.
sudo cp /opt/socialsecretary/stubs/socialsecretary_config.mjs \
/opt/socialsecretary/socialsecretary_config.mjs
sudo chown secretary:secretary /opt/socialsecretary/socialsecretary_config.mjs
sudo nano /opt/socialsecretary/socialsecretary_config.mjs
This is the one file that holds your settings. It is kept separate from the code so that updating SocialSecretary never touches it. Every field is explained in the file. The ones you must set:
domain — YOUR_DOMAIN.COMactor — YOUR_HANDLE, matching
preferredUsername in actor.jsonbaseDir — /opt/socialsecretary, unless you
installed elsewhereEverything else has a default that is right for a first installation.
Leave deliveryAllowlist alone for now; Step 11 uses it.
Before handing SocialSecretary to systemd, run it once in the foreground so you can see it start:
cd /opt/socialsecretary
sudo -u secretary node index.mjs
You should see:
[index] SocialSecretary starting
[index] actor: @YOUR_HANDLE@YOUR_DOMAIN.COM
[index] port: 3000
[index] outbox: /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/outbox
[index] watching outbox: ...
[index] HTTP server listening on port 3000
On this first run SocialSecretary creates
actors/YOUR_DOMAIN.COM/YOUR_HANDLE/ and everything inside
it. Press Ctrl-C to stop it. Then:
ls -la /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/
The directories from the layout in "Before You Begin" are now there,
owned by secretary.
If instead you see private key not found, the key is not
at the path config.mjs derives from your config:
baseDir/keys/domain/actor/private.pem. Compare that path,
character for character, with where Step 5 put the file.
Port 3000 is not reachable from the internet — Caddy talks to it on localhost. Nothing in this guide opens it in a firewall, and nothing should.
Optional: denylist.txt. One of the
stubs from "Before You Begin" hasn't been used yet. If you already know
domains you want to refuse from the start, copy it in now that the actor
directory exists to hold it:
sudo cp /opt/socialsecretary/stubs/denylist.txt \
/opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/denylist.txt
sudo chown secretary:secretary \
/opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/denylist.txt
One domain per line. It's read on every request — no restart needed,
ever, even later — and an empty or absent file blocks nothing. Most
installs skip this at first and add entries later, from experience; see
docs/OPERATOR.md §4.8 for how blocking actually behaves
once it's in place.
SocialSecretary must start on boot, restart if it crashes, and run as
secretary. On Ubuntu that job belongs to systemd, which is
already running every other service on the machine.
sudo cp /opt/socialsecretary/stubs/socialsecretary.service \
/etc/systemd/system/socialsecretary.service
sudo systemctl daemon-reload
sudo systemctl enable --now socialsecretary
sudo systemctl status socialsecretary
enable makes it start on boot; --now starts
it immediately. status should show
active (running), and the last lines should be the same
startup log you saw in Step 9.
The service file is short enough to read in full, and you should:
[Unit]
Description=SocialSecretary — headless ActivityPub sidecar
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=secretary
Group=secretary
WorkingDirectory=/opt/socialsecretary
ExecStart=/usr/bin/node /opt/socialsecretary/index.mjs
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/socialsecretary
[Install]
WantedBy=multi-user.target
The lines that matter: User=secretary runs it as the
service user; Restart=on-failure restarts it if it exits
with an error, five seconds later; ProtectSystem=strict
with ReadWritePaths=/opt/socialsecretary makes the entire
filesystem read-only to the process except its own directory, which is
the promise of Step 2 enforced by the kernel rather than by file
permissions alone.
Why systemd and not pm2. Earlier versions of this guide used pm2, the Node process manager, and a first production install ran on it. It was replaced for three reasons, in order of how much they cost.
First, pm2 keeps its state per user, under $HOME/.pm2.
Running SocialSecretary as a dedicated user means every pm2
command must name that user and that directory, and any command that
forgets talks to a different pm2 — one that reports nothing
running when SocialSecretary is running, or starts a second copy beside
the first. Both of those happened. The second copy fought the first for
port 3000 and the pair restarted each other twenty-three times. systemd
has one daemon and one process list; systemctl start on a
running service is a no-op.
Second, pm2 is installed from npm, globally, as root. It would have been the only npm package in a project whose whole design is that there are none.
Third, systemd is already here, already managing Caddy, already writing to the journal you will use for every other service. One process manager for the whole machine is simpler than two.
Reading the log, from now on:
sudo journalctl -u socialsecretary -f # follow live
sudo journalctl -u socialsecretary -n 100 # last 100 lines
sudo journalctl -u socialsecretary --since today
Restarting, after a code update or a config change:
sudo systemctl restart socialsecretary
ActivityPub.Academy is a Mastodon instance built for testing ActivityPub implementations — no real people, no social dynamics, and it shows you the raw activities it sends and receives. Test here before any real instance sees you.
First, restrict delivery to the Academy so that nothing you do while
testing can reach anyone else. In
socialsecretary_config.mjs:
deliveryAllowlist: ['activitypub.academy'],
then sudo systemctl restart socialsecretary. This is a
delivery filter, not a follower filter: followers elsewhere stay in
followers.json and start receiving posts the moment you set
the list back to [].
Test 1 — Discovery. Create an account on the Academy
and search for @YOUR_HANDLE@YOUR_DOMAIN.COM. Your profile
should appear, with your display name and (after Step 12) your avatar.
If it does not, Step 7's two curl checks are the first
thing to re-run.
Test 2 — Follow. Follow yourself from the Academy account. In the journal you should see the Follow arrive, be verified, and be accepted:
sudo journalctl -u socialsecretary -n 20
and the Academy should show the follow as accepted within a few
seconds. followers.json now has one entry.
Test 3 — Broadcast. Write a test envelope into the outbox. This is the same thing your publishing tool will do; you are doing it by hand once.
sudo -u secretary tee /opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/outbox/test-001.json > /dev/null << 'EOF'
{
"version": "1",
"type": "create",
"id": "test-001",
"url": "https://YOUR_DOMAIN.COM/test-001",
"published": "2026-09-05T12:00:00Z",
"content": "<p>Hello from SocialSecretary.</p>"
}
EOF
curl -X POST http://localhost:3000/trigger
Set published to now, in UTC. The journal should show
the envelope picked up, accepted into the archive, and delivered —
1/1 delivered. The post appears in your Academy
timeline.
The url in this envelope points at a page that does not
exist. For a test that is fine: the Academy displays the post from the
content you sent. For real posts, url must be a real page —
see "Connecting a Publishing Engine" below.
When all three pass, set deliveryAllowlist: [] and
restart. You are live.
Accounts without avatars read as suspicious or automated to human followers. This is a Fediverse social fact, not a technical requirement, and it is worth five minutes.
Prepare a square image, at least 400×400 pixels, under 1 MB, PNG or JPEG.
sudo cp your-avatar.png /opt/sites/YOUR_DOMAIN.COM/public/avatar.png
curl -sI https://YOUR_DOMAIN.COM/avatar.png | grep -i content-type
It must say image/png (or image/jpeg). If
it says text/plain, the Caddyfile catch-all intercepted the
request; the stub already has a handle /avatar.png block,
so check that it is still above the catch-all.
Add to actor.json, before the publicKey
block:
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://YOUR_DOMAIN.COM/avatar.png"
},
Re-run the JSON check from Step 6. No restart is needed —
actor.json is a static file. New followers see the avatar
immediately; existing ones see it when their instance refreshes its
cached copy of your actor document, which takes hours to a few days.
@YOUR_HANDLE@YOUR_DOMAIN.COM exists in the Fediverse. No
platform, no instance account, no database. One Node process, two JSON
files, a Caddy config, and a service unit — every one of which you
placed yourself and can find again.
From here:
docs/OPERATOR.md)
is how you look after it — what to check weekly, what the directories
accumulate, what a warning in the journal means, and — §4.7 — how to
deploy a new release onto an install that's already running. That
procedure isn't repeated here: Step 3 above is how you get the
first copy of SocialSecretary onto a fresh server; §4.7 is how
you replace it later without disturbing your config, keys, or data, and
it's the one place that procedure is written down.docs/handoff-envelope-spec.md) is the full contract for
envelopes: updates, deletes, visibility, replies, mentions, attachments,
likes and boosts.docs/mailbox-spec.md) is the contract for reading what the
Fediverse sends you.docs/activity-log-spec.md) is the contract for the third
direction: what Secretary itself did with the other two — every
delivery, every resolved or unresolved mention, every follow and accept
— as a plain, append-only file your tool can read without an API.Drop a JSON envelope in the outbox and SocialSecretary broadcasts it:
/opt/socialsecretary/actors/YOUR_DOMAIN.COM/YOUR_HANDLE/outbox/
The smallest useful envelope:
{
"version": "1",
"type": "create",
"id": "my-first-post-20260905120000",
"url": "https://YOUR_DOMAIN.COM/posts/my-first-post",
"published": "2026-09-05T12:00:00Z",
"title": "My First Post",
"summary": "A short teaser shown before the fold.",
"content": "<p>The post, as HTML.</p>"
}
Two fields need care. id must be unique and must be
reproducible — an update or delete later must use the same
id, so derive it from something stable rather than a random
value. url must be a real page on your site that serves the
post, because other servers fetch it to display your post and to confirm
it is yours.
SocialSecretary watches the outbox and picks new envelopes up on its own within a few seconds. To broadcast immediately, ping the trigger endpoint after writing the file:
curl -X POST http://localhost:3000/trigger
Both paths run the same pipeline. The trigger exists because file watching is unreliable on some filesystems; the watcher exists so a tool that cannot make an HTTP request still works.
That is the whole interface. Your publishing tool never imports SocialSecretary's code, never reads its internals, never touches the archive. A file in a directory and an optional ping.
Updates, deletes, followers-only and direct posts, replies, mentions, attachments, likes, boosts, and retracting a like or boost are all envelopes too. The Handoff Envelope Specification defines every field; the example above covers a small fraction of it.
The other direction — replies, likes, boosts, mentions arriving from
the Fediverse — lands in mailboxes/, sorted by kind, one
JSON file per activity. The Mailbox Specification defines what your tool
will find there.
systemctl status shows failed or
activating (auto-restart).
sudo journalctl -u socialsecretary -n 50 shows the error.
The three most common on a fresh install: private key not at the derived
path (Step 5), a syntax error in socialsecretary_config.mjs
(Step 8), or a permissions error because something under
/opt/socialsecretary is not owned by secretary
(Step 3, chown -R).
This can also happen on an install that has been running fine for
months: socialsecretary_config.mjs still contains
YOUR_DOMAIN.COM because the stub template got copied over
the live file — an installer template and a live, edited config look
alike enough to swap by mistake, and it has happened more than once in
production. The error names the placeholder value directly, so it is not
a guessing game; the fix is restoring your real config, not editing this
one back into shape. See docs/OPERATOR.md §4.1 and §4.7,
and the project ledger entries §4.7 points to, for why this specific
mix-up gets its own paragraph instead of being folded into "syntax
error" above — it is not a syntax error, it is valid JavaScript with the
wrong values, and node --check will find nothing wrong with
it.
Follows are not received. Confirm Caddy routes
/inbox to port 3000: sudo caddy validate and
look at the handle /inbox block. Confirm the domain
resolves and actor.json is served with the right content
type (Step 7's checks). Look for
signature verification failed in the journal, which means
the sender could not be verified — usually a clock more than a few
minutes wrong on your server (timedatectl).
Posts are not broadcast. Check
last_broadcast.txt in the actor directory: an envelope
whose published is older than that timestamp is skipped as
already sent. Check failed/ for the envelope and its
.error.txt. Try the trigger by hand and watch the
journal.
Discovery fails — handle not found. Run the
webfinger curl from Step 7 exactly as written. The
subject in the file must match the search query byte for
byte, including acct:.
Avatar not showing. Step 12's content-type check. If correct and it is still missing, the remote instance has a cached actor document; wait, or search your handle from that instance to prompt a refresh.
Posts show on the Academy but not on a real
instance. Real instances fetch url and expect
either an HTML page or, when they ask for
application/activity+json, an ActivityPub object. If your
site does not yet answer at that URL, that is the cause. zdat's site
does this; any other publishing engine must be taught to.