A terminal social media manager. One place to log in, write, schedule and post to every network you use.
myna # the TUI
myna login bluesky # connect an account
myna post all "the release notes are up"
echo "shipping today" | myna post bluesky,mastodonBuilt on HQTUI. Credentials are encrypted on your own machine and nothing is sent anywhere except the posts you make.
- Post once, everywhere. Text is tailored per network: character limits, URL weighting, threads where they exist, truncation where they do not.
- Schedule.
myna schedule "tomorrow 9am"queues a post; the scheduler sends it while the TUI is open, or runmyna runas a daemon. - Read back. Home timelines, mentions, per-post engagement, and a history of what landed and what failed.
- See how it did. A performance screen with volume over time, delivery rate, per-network breakdown, the hours you actually post, and your best posts.
- Write, optionally. Paste a link and get a drafted post with hashtags that suit each network. The writer never posts on its own; drafts land in the compose box for you to edit.
- Infographics. The model picks the copy, myna renders the graphic. Text on the image is exactly the text in the copy.
- Grow the right audience. Seed a follow graph with people worth learning
from, let myna read who they follow, and follow the accounts they agree
on, a few an hour, from
myna run. See The follow graph. - Plugins. A plugin can add a network, a command, a daemon task or a source of people to follow. The bundled one pulls seeds from OutreachGraph. See Plugins.
curl -fsSL https://mynaposter.com/install.sh | shA single binary with the runtime compiled in, for Linux, macOS and Windows. Nothing else needs to be installed first.
The script checks the download against the published SHA256SUMS before it
writes anything, stages the binary beside the target and renames over it so a
running myna is never half-replaced, and picks the AVX2-free build on older
CPUs. MYNA_BIN chooses the directory, MYNA_VERSION pins a version. Read it
first if you would rather not pipe a script — it is at
mynaposter.com/install.sh, and the binaries
are on the releases page
if you want to verify them yourself.
Already have a package manager:
bun add -g @profullstack/myna # or: npm i -g @profullstack/mynaThat route needs Bun 1.1+ or Node 22.6+.
myna update # replace this binary with the newest release
myna update --check # only say whether one is waitingupdate does what the installer does, from inside myna: it picks the asset this
machine can run (including the AVX2-free build on older CPUs), checks it against
the published SHA256SUMS, and renames it over the running binary rather than
writing into it, so a failed download leaves the working copy untouched.
--check exits 1 when an update is waiting, which is the answer a cron job
wants. --version <x.y.z> installs a specific release, up or down, and
--force reinstalls the one you already have.
A myna running from a checkout or a global bun/npm install has no binary to
replace, so update says which release is newest and how to update the way it
was installed, instead of overwriting Bun.
/login <network> asks for whatever that network actually accepts. This is the
part most tools are vague about, so to be plain:
| How you log in | Networks |
|---|---|
| Real username and password | Bluesky (app password), Lemmy, Matrix, Mattermost, WordPress (application password), Reddit (script app) |
| A token you paste | Telegram, Discord, Slack, Misskey, Nostr, dev.to, Hashnode, Ghost, Micro.blog |
| Approving a short code | tsbb, agenticjobs (device flow: the board shows a code, you approve it in a browser) |
| Nothing — files on this machine | Git blog (a repository, committed through GitHub with your token or gh), HTML blog (a directory of pages) |
| One click in a browser, no setup | Mastodon, Pleroma, Akkoma, GoToSocial, Pixelfed. myna registers itself on the instance and opens an Authorize page. Nothing to type but the instance, and no developer account anywhere |
| App keys | Tumblr |
| Browser sign-in (OAuth) | X, Facebook, Instagram, Threads, LinkedIn, Pinterest, TikTok, YouTube |
Two of those rows are browser sign-ins for opposite reasons. Mastodon and its relatives let any client register itself, so the browser flow needs no setup at all: you type the instance and click Authorize. X, Meta, LinkedIn, Pinterest, TikTok and YouTube require you to register an app on their developer portal first, because they removed password APIs years ago and scraping a login session is both against their terms and fragile enough to break without warning.
Anything a login would ask for can be given on the command line instead, in the
order the network declares its fields or as --<field>, and what you pass is
never asked for again:
myna login tsbb https://bbs.hqtui.com/ --forum app-showcase
myna login tsbb https://bbs.hqtui.com/ app-showcase # same thing, positionallyThat makes a login scriptable, and outside a terminal a missing required field
is an error naming the flag rather than a prompt nobody can answer. /login tsbb https://bbs.hqtui.com/ does the same in the TUI: the dialog opens with the
board filled in and the cursor on the first empty box.
A board is a set of forums, so tsbb takes several and cycles through them. Login reads the board's forum list, and either takes the slugs you name or asks which ones, checking them against the board before it asks anyone to approve a code:
myna login tsbb https://bbs.hqtui.com/ --forum app-showcase,announcements,newsEach post then goes to the next forum in that list rather than to all of them
at once, which is what a board would read as spam. myna post --forum <slug> is
a detour for one post and leaves the rotation where it stands.
Mastodon no longer accepts a password at all. grant_type=password was
removed; a current server answers unsupported_grant_type. myna used to offer
the field and produced a confusing failure, so it does not any more.
Two more things worth knowing before you plan a posting workflow:
- Facebook and Instagram cannot post to a personal profile at all. Facebook needs a Page you administer; Instagram needs a Business or Creator account linked to one, and its API will only fetch images from a public URL.
- Reddit script apps do not work on accounts with 2FA, and Mastodon's password grant is refused by instances with 2FA. Both fall back to a token.
30 in total.
Major X, Facebook, Instagram, Threads, Bluesky, Reddit, LinkedIn, Pinterest,
TikTok, YouTube
Fediverse and self-hosted Mastodon (and Pleroma, Akkoma, GoToSocial),
Misskey (and Sharkey, Firefish), Pixelfed, Lemmy, Nostr, tsbb, agenticjobs
Chat Telegram, Discord, Slack, Matrix, Mattermost
Long-form dev.to, Hashnode, Ghost, WordPress, Micro.blog, Tumblr (named in --to, never part of all)
Your own blogs Git blog, HTML blog
Your calendar Google Calendar (an event is a post; see Calendar)
myna networks prints the current list with each one's login method and limit.
agenticjobs holds two different things and myna treats them as two different things.
A job opening is a publication, and stays where it was: myna jobs post opening.md, named explicitly, never part of a fan-out. A status update turning
into a job opening at your company is not something you can delete your way out
of.
An update is a status post — a role filled, something shipped, who is free
next — and that is what the agenticjobs network posts. It goes out with the
rest of them.
myna login agenticjobs agenticjobs.work --org acme # device flow; --org is optional
myna post "shipped resume downloads" --to all # the board is included
myna post --to agenticjobs "we closed the backend role https://acme.dev/blog"
myna follow agenticjobs acme # or candidate:ada, or a page URLLeave --org off and you post as yourself, which the board only allows if you
have published a resume there, so an update always has a page behind it. A
trailing URL moves into the board's own link field rather than being printed
twice, since the board renders the link under the body. 600 characters, five a
day, and the same text twice is refused.
myna will not tell you who somebody else follows on that board. It is a job board: that list is which employers a person is looking at.
Two networks cover nearly every blog that is not a CMS, and both take the post as Markdown:
- Git blog (
gitblog) — a repository where a post is one Markdown file with frontmatter (content/blog/<slug>.md, the Next.js, Astro and Hugo shape). myna commits the file through the GitHub API, so no checkout is needed and the site's own deploy publishes it. The token is optional:GH_TOKENorgh auth tokenis used when none is stored. - HTML blog (
htmlblog) — a directory of plain pages where writing the file is publishing. myna writes the nextNNN-post.html, lists it inindex.html, runs the blog'sbuild-feed.mjs, and pushes a mirror repository if you name one. When cli-tools'blog-postis on the PATH the page is written by it, byline and analytics tags included.
myna login gitblog # repo, posts directory, branch, where posts appear
myna login htmlblog # directory, public URL, optional mirror checkout
myna post --to gitblog "Release 1.2
The first paragraph is the description.
## What changed
..." --title "Release 1.2" --tags "release, cli"
myna post --to htmlblog --description "One line for the feed" < post.mdBoth are never part of all, and neither is any long-form network (dev.to,
Hashnode, Ghost, WordPress, Micro.blog, Tumblr). A social post fanned out by
accident is an embarrassment; an article fanned out by accident is a
publication (and on your own blog, a commit), so those only post when named in
--to. One post can name several: --to htmlblog,devto writes the page and
the article from the same Markdown. Per-post flags: --slug,
--description, --tags, --date (the future is refused), --draft true,
--author, --canonical-url, and --overwrite true for a Git blog.
--to htmlblog,devto publishes the same article twice, so one of them has to
be the original or search engines pick for you. --canonical-url says which:
myna post --to devto --title "Release 1.2" \
--canonical-url https://example.com/blog/042-post.html < post.mdIt is honored wherever the network has a field for it, and the field is different every time:
| network | what it sends |
|---|---|
| dev.to | canonical_url |
| Hashnode | originalArticleURL |
| Ghost | canonical_url |
| Tumblr | source_url, the attribution link it has instead |
| gitblog | canonical: in the post's frontmatter, for the site template to render |
| htmlblog | <link rel="canonical"> in the page head |
WordPress and Micro.blog are left out on purpose. WordPress core has no canonical field (it belongs to an SEO plugin's post meta) and Micropub defines no canonical property, so neither pretends to support one.
htmlblog points at itself without being asked, using the siteUrl you
logged in with, because the original should confirm what the copies claim.
Pass --canonical-url only when the original really is elsewhere. When
profullstack/cli-tools' blog-post writes the page, it applies its own
siteUrl and myna forwards the flag only when you set one — that needs
cli-tools 0.28.0 or newer.
YouTube has no timeline to post into. What it has is videos, and under each one a comment thread, so on YouTube a "post" is a comment on a video you name. The useful loop is to search for videos on your subject and comment where the conversation already is:
myna search youtube "terminal social media manager"
myna post youtube "If you want this from a terminal, myna does it: https://mynaposter.com" --video dQw4w9WgXcQ
myna post youtube "Thanks, fixed in 0.4" --reply-to UgzQK7s8m1Xf3kR9pL54AaABAg
myna post youtube "Release walkthrough" --media walkthrough.mp4 --privacy unlisted--video takes an id or any YouTube link. myna search lists the id, channel
and link for each hit, and --json gives the same for a script. A plain
myna post youtube with no video and no video file is an error, on purpose:
there is nothing on YouTube it could sensibly go to.
Two things to know. Uploads from an app that has not passed Google's verification are forced to private, which is Google's rule, not myna's. And YouTube filters repeated identical comments as spam, so write for the video you are commenting on rather than pasting one line everywhere: a comment that answers what the video is about, with your link, lands. The same one pasted under twenty videos disappears.
compose accounts queue history feed networks help all 4
╭─ Compose ─────────────────────────────────────────╮ ╭─ Goes to ──────────────╮
│ myna is a terminal social media manager. One │ │ bluesky:alice 139/300│
│ command posts to every account you own. │ │ mastodon:@alice 139/500│
│ │ │ x:@alice 140/280│
│ https://mynaposter.com │ │ reddit:u/alice 139/4000│
╰───────────────────────────────────────────────────╯ ╰────────────────────────╯
╭─ Command ──────────────────────────────────────────────────────────────────────╮
│ /link https://example.com/post │
╰────────────────────────────────────────────────────────────────────────────────╯
myna / for commands Enter to edit the post Ctrl+S to send accounts 4
The right pane counts each network as that network counts, so x reads 140 while
the others read 139: X bills every URL at 23 characters regardless of length.
Keys. / command bar, Enter edit the post, Ctrl+S or F2 send, Ctrl+T pick
targets, Esc back, Tab next tab (or complete a half-typed command), Shift+Tab
previous tab, 1–8 switch screen, Ctrl+C quit. Pasting works in every field.
Every slash command in the TUI is also a subcommand, so anything you can do by hand you can put in a script.
myna login <network> myna post [target] [text]
myna logout <account> myna schedule "in 2h" "text"
myna accounts myna queue / cancel <id>
myna networks myna history
myna feed [network] myna delete <account> <id>
myna draft "<topic>" myna link <url>
myna infographic <url|topic> myna run
myna config [key] [value] myna doctor
myna keys myna repost <account> <post url>
myna search [network] <query> myna follow <account> <handle>
myna following <account> [handle] myna graph <subcommand>
myna plugins [add|remove] myna outreachgraph <subcommand>
myna crawlproof <subcommand> myna calendar <subcommand>Flags: --to, --title, --media, --style, --json, --dry-run,
--no-thread, --limit, --force. Any other --flag value is handed to the
network as an option: --video and --reply-to for YouTube, --subreddit for
Reddit, --privacy for an upload.
--json on any read command gives machine output, so myna accounts --json | jq
works the way you would expect.
Following someone's followers is noise: anyone can follow an account, and most who do are bots, fans and the idle. Following who they follow is the opposite. It is a list a person you already trust curates by hand, and when several of those people follow the same account, that account is the one to follow first. That is the whole idea:
seeds ──who they follow──▶ candidates ──ranked, a few an hour──▶ follows
myna graph seed bluesky jay.bsky.team pfrazee.com --weight 2 # people worth learning from
myna graph seed mastodon Gargron@mastodon.social
myna graph expand # read who each seed follows
myna graph candidates # best first: score, how many seeds, who
myna graph follow --limit 5 --dry-run # what would go out
myna graph follow --limit 5 # send them
myna graph on && myna run # or let the daemon do it, 10 an hourA candidate's score is the summed weight of the seeds who follow them, so a
person three seeds all follow outranks a person one seed follows, and a seed
with --weight 2 counts double. The seeds themselves are candidates too. Your
own accounts never are, and neither is anyone you have already followed, marked
with myna graph skip, or failed to follow three times.
The daemon follows at most graph.followsPerHour (10) and graph.followsPerDay
(80) per account, spread evenly across the hour rather than in a burst, which
is what gets a new account flagged. Every attempt counts against the ceiling,
successful or not. myna graph status shows the budget each account has left;
myna config graph.followsPerHour 5 changes it. graph.networks restricts
the daemon to some networks (bluesky,mastodon), and graph.minSeeds 2 means
nobody is followed on one seed's word alone.
Two direct commands sit under the graph: myna following <account> [handle]
lists who anyone follows (yours by default), and myna follow <account> <handle>
follows one person now.
Works on Bluesky, Mastodon and its relatives, Misskey, X and Nostr. Two
caveats. X reads following lists only on the Basic tier and above; on a
free app the request answers 403, and an account signed in before this
release needs myna login x again so its token carries the follows scopes.
Nostr refuses to follow from an account with no contact list on its
relays: a follow is a new kind 3 event that replaces the old one everywhere,
so myna will only extend a list it can find, never publish one that would wipe
what another client wrote. Follow one person from any other Nostr client first.
A plugin is an ES module whose default export describes what it adds:
networks, commands, daemon tasks, sources of seeds for the follow graph, and
hooks: afterPost hears about every post once it is out, afterSchedule and
afterCancel about every queue entry as it is made and removed. The bundled
outreachgraph, crawlproof and calendar plugins are the references; read
docs/plugins.md to write one.
myna plugins # what is loaded, and what each adds
myna plugins add @someone/myna-plugin-foo # from npm, into ~/.config/myna/plugins
myna plugins add ./my-plugin # or a directory on disk
myna plugins remove fooPlugin commands run as myna <command>; plugin tasks run inside myna run;
a plugin's secrets live in the same encrypted vault as your accounts.
OutreachGraph finds the people who matter for what you sell and ranks them by opportunity. The bundled plugin pulls that list, keeps the ones with a Bluesky, Mastodon, X, Nostr or Misskey identity, and hands them to the graph as seeds, weighted by their score. The graph then does the rest: reads who those people follow, and follows the accounts they agree on.
myna outreachgraph login # email + password, stored in the vault
myna outreachgraph people # the ranked list, with the handles myna can use
myna outreachgraph sync # pull them in as seeds now
myna graph on && myna run # the daemon re-syncs every six hoursCrawlProof runs an ad network across the sites that
carry its slots. The bundled plugin turns a blog post into a campaign the
moment it is published: after myna post lands a page on a Git blog or an
HTML blog, the page's URL goes to CrawlProof, which reads it, writes the
creatives and starts serving. Social posts do not get a campaign of their
own; --ad true on any post runs one for the first URL in it.
myna crawlproof login # paste an API token from Social → API tokens
myna post --to htmlblog < post.md # …and the new page gets a campaign
myna crawlproof ad https://example.com/launch --budget 300
myna crawlproof ads # campaigns, newest first
myna crawlproof ads show crawlproof-ad-144 # delivery, and the visits it sent
myna crawlproof ads pause crawlproof-ad-144 # or resume, budget <cents>, delete --yes
myna crawlproof auto off # stop the automatic onesThe bundled calendar plugin brings Google Calendar in as a network, gcal,
and mirrors the queue onto it. Every post that goes through myna schedule
gets an event at the moment it is due, with the text and the targets in the
description; myna cancel takes the event away again. An event is also just a
post to gcal, which is never part of all.
myna login gcal # a Google Cloud OAuth client with the Calendar API enabled
myna schedule "2027-04-01 9am" "…" # queued, and on the calendar
myna calendar list --days 30 # what is coming up
myna calendar add "tomorrow 9am" "Standup" --duration 30m --location "Room 1"
myna post --to gcal --at "friday 3pm" --title "Release review" "Agenda inside"
myna feed gcal # the next events, as a timeline
myna calendar calendars # which calendars the account can write to
myna calendar auto off # stop mirroring the queueGoogle is the first provider; the network is gcal so another calendar can be
another network in the same plugin. While the OAuth client's consent screen is
in testing, Google expires the sign-in after seven days; publish the consent
screen and the sign-in is permanent.
Off unless you configure it. It drafts; you decide.
myna config ai.provider anthropic # or openai, or ollama
myna config ai.voice "Plain, specific, no hype."
myna link https://example.com/post --to allAnthropic is the default and uses ANTHROPIC_API_KEY. OpenAI uses
OPENAI_API_KEY. Ollama needs no key and talks to OLLAMA_HOST.
myna infographic https://example.com/report --style html
myna post all --media /tmp/.../infographic.png "the numbers are in"Three backends:
svgrenders a built-in template. Offline, no AI, exact text.htmlhas the model write HTML and CSS, then screenshots it. Real text, better design.imagehands the whole thing to an image model. Good for illustration, and the only one where the text can come out wrong.
The first two exist because image models rewrite words on the way through: invented figures, misspelled names, quotes nobody said. Letting the model choose the copy and rendering it ourselves removes that failure entirely.
Rendering to PNG needs one of Chrome/Chromium, rsvg-convert, ImageMagick or
Inkscape. myna finds browsers that Playwright or Puppeteer already downloaded.
myna doctor reports what it found.
myna save ~/myna.myna # on the laptop
scp ~/myna.myna server: # however you like
myna load ~/myna.myna # on the serverA bundle holds the connected accounts, the pending queue and the settings. It is always encrypted with a passphrase you type, never with the local keyfile: the keyfile is machine-specific so a bundle sealed with it could not be opened anywhere else, and the file holds a live token for every account you have. There is no plaintext option for that reason.
load is additive and shows you the effect before causing it. An account that
already exists here is kept, not replaced, unless you pass --overwrite —
tokens get refreshed in place, so a bundle taken last week can carry one that has
since been rotated, and silently clobbering a working account with a stale token
is the failure that would be hardest to notice.
Optional. myna never contacts a server unless you run a cloud command.
myna cloud signup you@example.com
myna cloud push # encrypts here, then uploads
myna cloud pull # on the other machineWhat goes up is the same bundle save writes: sealed on your machine with a
passphrase that never leaves it. The server stores ciphertext it cannot read
and refuses to store anything that is not already sealed. That is the only
arrangement under which keeping tokens for 26 social networks on somebody
else's computer is a reasonable thing to do — a full compromise of the server
yields nothing.
Sign-in is email and password. The house pattern is magic link and passkey, with an optional password for devices those cannot reach; a terminal has no mail client to open a link in and no authenticator to hold a passkey, which is that exception exactly.
Self-host it by pointing MYNA_SERVER at your own instance.
myna is one core with four faces. An account connected in any of them works in all of them, because they read the same vault.
apps/cli |
The TUI and the scriptable CLI |
apps/desktop |
An Electron app, same core |
apps/api |
An HTTP API for scripts and cron |
packages/mcp |
An MCP server, so an agent can post for you |
Accounts are keyed by network:handle, so as many as you like can coexist:
myna post bluesky "goes to every Bluesky account"
myna post bluesky:work.bsky.social "just the work one"
myna post bluesky,mastodon:@me@example.com "mix and match"Naming a network and one of its accounts together still posts once to each.
export MYNA_API_TOKEN=$(openssl rand -hex 32)
bun apps/api/src/server.ts
curl localhost:8787/v1/networks
curl -X POST localhost:8787/v1/post \
-H "authorization: Bearer $MYNA_API_TOKEN" \
-H "content-type: application/json" \
-d '{"text":"shipping today","to":"all"}'Reads are open when no token is set; writes are refused outright rather than
left unauthenticated. DATABASE_URL decides the shape: unset means single-user
against ~/.config/myna, set means the hosted Postgres schema in
apps/api/src/db/schema.sql.
Postgres runs in a container you control, not a managed add-on:
docker compose up -d postgres
export DATABASE_URL=postgres://myna:myna@127.0.0.1:5432/myna
bun run db:migrate{ "mcpServers": { "myna": { "command": "bunx", "args": ["@profullstack/myna-mcp"] } } }Eleven tools: myna_accounts, myna_networks, myna_preview, myna_post,
myna_schedule, myna_queue, myna_cancel, myna_history, myna_draft,
myna_timeline, myna_search.
There is deliberately no login tool. Connecting an account means typing a
password or completing a browser flow, and that belongs to a person. myna_post
publishes immediately and cannot be undone on every network, which its
description says plainly; myna_preview is there to check the targets and the
per-network tailoring first.
.railway/railway.ts defines the two deployable services. One thing to know
before editing it: the file is declarative for the whole Railway project, so
without export const partial it plans to delete every service it does not
mention. Run railway config plan and read the destroy count before applying.
~/.config/myna/
vault.json accounts and credentials, AES-256-GCM
vault.key the key, when not using a passphrase (0600)
queue.json scheduled posts
history.json what was sent
graph.json follow graph: seeds, candidates, and every follow sent
settings.json preferences
plugins/ plugins installed with `myna plugins add`
Plugin secrets (an OutreachGraph login, say) are inside vault.json beside the
accounts, not in settings.json.
The vault is encrypted with a local keyfile by default, so myna does not ask for a master password on every launch. To use a passphrase instead:
MYNA_PASSPHRASE="…" myna doctorSet MYNA_HOME to keep everything somewhere else.
bun install
bun run cli # the TUI from source
bun test # unit tests; no database needed
bun run typecheckThe cloud tests need a real Postgres and skip themselves without one:
bun run db:up
export DATABASE_URL=postgres://myna:myna@127.0.0.1:5432/myna?sslmode=disable
bun apps/api/src/db/migrate.ts
bun test apps/api/testThe Nostr signing is BIP340 Schnorr implemented over BigInt, because node:crypto
exposes no Schnorr primitive. It is checked against the BIP340 reference vectors
in packages/core/test/schnorr.test.ts.
packages/core adapters, vault, scheduling, the writer, infographics
apps/cli the TUI and the scriptable CLI
MIT