weone-daily-post
Enables automated creation and publishing of branded Instagram and Facebook posts, with topic management, typeset image rendering, and social media publishing via the Meta Graph API.
README
weone-daily-post — remote MCP server
Publishing backend for the We One Aviation daily Instagram + Facebook post. It is a stateless Streamable HTTP MCP server: Claude does the thinking (topic choice, wording, caption), this service does the side effects (history, image rendering, storage, Graph API). Images are typeset, not generated: the text on the poster is exactly the text supplied.
Claude ──POST /mcp (Bearer)──▶ Render web service (Node 20, Express)
├─ Supabase posts table + post-images bucket
├─ Chromium HTML template → JPEG
└─ Meta Graph IG container/publish, FB photos
Tools
| Tool | Purpose |
|---|---|
get_past_topics() |
Newest-first history, max 200 rows: {id, topic, category, status, created_at}. Read it before choosing a topic. |
reserve_topic(topic, category) |
Inserts status='reserved', returns {id}. category ∈ news, subject, career. A repeat topic fails with duplicate_topic. |
render_post({template, headline, points, footer?, eyebrow?, slug?}) |
Renders a branded HTML template to an exact-size sRGB JPEG, uploads it, and returns an image block plus the public URL. |
publish_socials(image_url, caption, hashtags, history_id) |
Publishes to Instagram then Facebook, records the outcome. |
mark_draft(history_id, image_url, caption) |
Shadow mode: record the finished post as draft without publishing. |
check_token() |
Days until the Meta token expires + granted scopes. |
Every tool returns JSON. Success is {"ok": true, ...}; failure is an MCP error
result containing {"ok": false, "error": {code, message, retryable, details}}.
Nothing throws a raw stack trace at the caller.
render_post
template is one of news, subject or career — the same three categories
the posts table uses.
| Field | Limit | Notes |
|---|---|---|
headline |
60 chars | Barlow 700, up to 3 lines. Sentence case, not title case. |
points |
3–4 items, 90 chars each | Barlow 400, one gold marker each |
eyebrow |
32 chars, optional | Gold, uppercased by CSS, e.g. NAVIGATION |
footer |
90 chars, optional | Left side of the footer bar, e.g. DGCA · 14 Aug 2026 |
It returns two content blocks: an image block (base64 JPEG) and a text block with the public URL, filename, dimensions, byte size and render time.
Two guards run before anything is uploaded, and both name the offending field:
- Length limits, checked before Chromium is touched — the cheap rejection.
points[2] is 97 characters, limit is 90. Shorten it and retry. - In-page measurement, after layout — every text box is a fixed-size clipping box, and if its content is taller or wider than the box the render is rejected with the field name and the overflow in pixels. This catches what a character count cannot see, such as one unbreakable 80-character token that is legal in length but runs off the edge.
Nothing is uploaded when either guard fires, so a rejection costs a second and the fix is always "shorten the named field".
The image block still comes back so you can read the wording in context, but it is no longer a correctness check: a fixed template cannot misspell a word or invent a diagram. Worst-case legal input (58-char headline plus four 90-char points, widest legal eyebrow and footer) has been verified to fit all three templates.
publish_socials, step by step
HEADtheimage_urland assert200+content-type: image/jpeg. Meta fetches this URL server-side, and a bad URL there fails opaquely hours later. (A store that rejectsHEADgets a one-byte rangedGETinstead.)- Instagram —
full_caption = caption + "\n\n" + hashtags.join(' '), capped at 2200 characters. Only hashtags are dropped, from the end; the caption body is never truncated. If the body alone exceeds 2200 the call fails withcaption_too_longbefore anything is posted.POST {IG_USER_ID}/media→ pollGET {container}?fields=status_code,statusonce a second for up to 60s → publish only onFINISHED. OnERRORthestatusstring is returned verbatim, because it is the only place Meta explains what it disliked. - Facebook —
POST {FB_PAGE_ID}/photoswithurlandmessage. Attempted regardless of the Instagram outcome. - Record — the
postsrow getsig_post_id,fb_post_id,image_url, andstatus=published(both),partial(one) orfailed(neither).
Returns {ig_post_id, fb_post_id, status, errors: [...]}. A single-platform
failure is never swallowed: it appears in errors[] with the platform, the
stage it failed at, and Meta's own code / error_subcode / message.
Environment variables
| Variable | Required | What it is |
|---|---|---|
MCP_AUTH_TOKEN |
yes | Shared secret for /mcp. The connector must send Authorization: Bearer <value>. If unset the server still boots and serves /health, but rejects every /mcp request with a 500 — it fails closed, never open. Generate one with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". |
SUPABASE_URL |
yes | https://<project-ref>.supabase.co. |
SUPABASE_SERVICE_KEY |
yes | Service-role key. Bypasses RLS — server-side only. Never put it in the connector config. |
META_GRAPH_VERSION |
no (default v23.0) |
Graph API version used for every call. |
IG_USER_ID |
for publishing | Instagram Business account id (a number, not the @handle). |
FB_PAGE_ID |
for publishing | Facebook Page id linked to that Instagram account. |
META_PAGE_ACCESS_TOKEN |
for publishing | Long-lived Page access token with instagram_basic, instagram_content_publish, pages_show_list, pages_read_engagement, pages_manage_posts. Expires around 60 days — check_token() tells you how long is left. |
PORT |
no | Render sets this. Default 10000. |
MAX_INLINE_IMAGE_BYTES |
no (default 1400000) |
Size above which the inline base64 preview is downscaled. |
CHROMIUM_EXECUTABLE_PATH |
no | Explicit path to a Chrome/Chromium binary. Overrides the per-platform default. |
CHROMIUM_SINGLE_PROCESS |
no | Set to 1 to force --single-process. Costs browser reuse — one render per launch. See Browser lifecycle. |
Copy .env.example to .env for local runs. .env is gitignored — keep it that way.
Setup
1. Supabase
Run migrations/001_init.sql in the SQL editor (or supabase db push). It is
idempotent and creates:
- the
poststable with the check constraints and the unique index ontopic— that index is the repetition guard, so a duplicate reserve is supposed to fail, created_at descandstatusindexes,- RLS enabled on
postswith no policies (only the service key gets in), - the public
post-imagesstorage bucket plus its public-read policy. Public read is mandatory: Meta fetches the JPEG itself and cannot present credentials.
2. Meta
You need an Instagram Business or Creator account linked to a Facebook Page,
and a long-lived Page token with the scopes listed above. Confirm it with
check_token() before the first run — an expired token is the single most
common cause of a failed morning.
3. Deploy to Render
With render.yaml (Blueprint):
- Push this repo to GitHub.
- Render dashboard → New → Blueprint → pick the repo. It reads
render.yaml: Node 20,npm ci && npm run build,npm start, health check on/health. - Render prompts for every
sync: falsevariable. Paste them. - Deploy, then check the logs for
server.listening ... auth=configured.auth=MISSINGmeansMCP_AUTH_TOKENdid not get set.
Manually:
- New → Web Service → connect the repo.
- Runtime Node, build
npm ci && npm run build, startnpm start. - Health check path
/health. - Add the environment variables from the table above, plus
NODE_VERSION=20.
Verify:
curl https://<your-service>.onrender.com/health
# {"status":"ok","server":{...},"tools":[...six...],"uptime_s":3}
Use the Starter plan, not free. Chromium needs roughly 400 MB resident on
top of Node, and the free instance is 512 MB — it will OOM mid-render, and the
failure shows up as a dead worker rather than a useful log line. Free also sleeps
after inactivity, so the first tool call of the day pays a 30–60 second cold
start on top. render.yaml sets starter for both reasons.
No browser download is needed at build time: @sparticuz/chromium ships its own
binary as a dependency, so npm ci && npm run build is the whole build. That
build step also copies src/templates/ into dist/ — tsc only emits .ts, so
without it the server starts fine and then fails on the first render with a
missing template file.
4. Connect it to Claude
The endpoint is:
https://<your-service>.onrender.com/mcp
with the header:
Authorization: Bearer <MCP_AUTH_TOKEN>
Claude Code / Cowork CLI:
claude mcp add --transport http weone-social \
https://<your-service>.onrender.com/mcp \
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
.mcp.json (project-scoped, checked in without the token):
{
"mcpServers": {
"weone-social": {
"type": "http",
"url": "https://<your-service>.onrender.com/mcp",
"headers": { "Authorization": "Bearer ${MCP_AUTH_TOKEN}" }
}
}
}
In the Claude desktop/web custom connector dialog, paste the same /mcp URL
and put the bearer token in the request-header field. Authentication is
header-only by design — the token is never accepted as a query parameter,
because URLs end up in proxy logs and browser history.
The daily-run instructions (brand rules, banned claims, category rotation, image
spec, QA checklist) live in the weone-daily-post skill, not in this server.
This service deliberately holds no editorial policy.
Local development
npm install
cp .env.example .env # fill it in
npm run dev # tsx watch, http://localhost:10000
npm run typecheck
npm run build && npm start
npm run smoke
npm run smoke # render all three templates, upload, print 3 URLs
npm run smoke -- --no-upload # render locally only, no credentials needed
Renders one of each template, writes all three JPEGs to ./out, uploads them,
HEAD-checks each public URL and prints the three links. It then proves both
guards still fire. It touches no Meta endpoint, so it is safe against
production credentials. Upload needs only SUPABASE_URL and
SUPABASE_SERVICE_KEY; --no-upload needs nothing.
Local files are written before the upload, so a Supabase failure still leaves something to look at.
Rendering pipeline
A headless Chromium loads src/templates/{template}.html over file://, the
values are written into the DOM, and the page is screenshotted. The same input
always produces the same pixels.
- Templates live in
src/templates/.tokens.cssholds every colour;base.cssholds the skeleton all three share. A template file differs from its siblings only in the eyebrow treatment and the point marker (news: gold rule, subject: numbered gold circles, career: gold chevrons). - Fonts are self-hosted in
src/templates/fonts/(Barlow 400/600/700 for everything, Cinzel 600 for the wordmark alone, latin subsets, OFL). Nothing is fetched at render time — a network call would make the output non-deterministic and would fail silently on Render, falling back to a system serif. The renderer waits ondocument.fonts.readyand then asserts both faces actually loaded rather than screenshotting a fallback. - No user text is ever concatenated into markup. Values go in through
textContentandcreateElement, so there is no escaping to get wrong: a<script>in a headline lands on the poster as the literal characters. - Viewport 1080×1350 at
deviceScaleFactor: 2, so the screenshot is 2160×2700 and downsampled — text edges stay clean. - sharp:
resize(1080, 1350, {fit:'cover'})→toColorspace('srgb')→jpeg({quality: 90, chromaSubsampling: '4:4:4'}), metadata stripped. 4:4:4 is not decoration — 4:2:0 smears coloured text edges, and these posters are text. - Asserts the encoded JPEG is under 8 MB and that the decoded dimensions really are what was asked for.
- Uploads as
{yyyy-mm-dd}-{slug}-{6 hex}.jpg(UTC date). Every render gets its own key and nothing is ever overwritten —upsert: false. Re-rendering a topic cannot change the picture under a post that already published the previous URL.cacheControlis 60s for the same reason: a bad object stays correctable within a minute instead of being pinned in a CDN for a year. Meta fetches the URL once, server-side, moments after upload, so nothing needs the long cache. Objects accumulate; storage is far cheaper than a stale image on a live post.
Layout behaviour
Type scales to the point count. Three points get a 68px headline and 36px
body; four get 60px and 32px. This is done in CSS with :has(), so the layout
decision lives entirely in the template and the renderer neither knows nor cares.
The overflow guard runs after scaling, so the scaled result is what gets measured.
The content block is vertically centred between the header rule and the footer bar. Fixed gaps alone cannot hold a fill target because the amount of text varies, so three elastic elements share the leftover space: a band above, a band below, and the gap under the headline. The bands are hard-capped at 150px, which is what enforces "no large empty margin"; once they cap, surplus goes into the headline gap where it reads as breathing room rather than a hole.
Measured vertical fill on representative content: 74–79%, bands 99–124px. A deliberately sparse case (one-line headline, three one-line points) sits at 68.8% with the bands at their 150px cap — with a hard cap on the bands that is the arithmetic maximum for that little text, and lifting it further would mean spreading the points so far apart they stop reading as a list.
Headlines are Barlow 700 in sentence case, leading 1.1, letter-spacing
−0.5px. Cinzel survives only in the "WE ONE AVIATION" wordmark. Sentence case is
not enforced in code — mechanically lower-casing a headline would wreck DGCA,
ATPL and AAI — so it is specified in the headline field description
instead.
Each template carries a flat SVG accent inline: a broad diagonal rule (news), concentric compass arcs (subject), a rising chevron stack (career). Gold at 7%, bleeding off the bottom-right behind the text. They exist to give the composition weight at thumbnail size and are far too faint to affect text contrast.
The accent sits inside
.anchor-wrap, a box pinned to the canvas withoverflow: hidden. Without it the absolutely-positioned graphic hangs past the bottom edge, counts towardbody.scrollHeight, and the overflow guard rejects every render with a constant 160px page overflow.
The logo
src/templates/assets/logo.png is the supplied lockup: the star/aircraft mark
above a "WE ONE AVIATION" wordmark. The header renders that wordmark itself in
Cinzel, so scripts/prepare-logo.mjs derives logo-mark.png — the mark alone —
to avoid printing the brand name twice. It finds the horizontal bands of
non-transparent pixels and keeps the tallest, so re-exporting the logo at another
resolution still works. After replacing logo.png:
npm run prepare-logo
Browser lifecycle and memory
One Chromium is shared for the life of the process and relaunched only if it disconnects. Launching costs about a second and a few hundred MB, far too much to repeat per post.
Render memory. Chromium needs roughly 400 MB resident on top of Node. The free instance is 512 MB and will OOM under it — the deploy dies mid-render with no useful log line. Use the Starter plan. If you must stay on free, expect restarts and treat the first render after each one as a cold start.
Where the binary comes from depends on the host:
| Host | Source |
|---|---|
CHROMIUM_EXECUTABLE_PATH set |
that path, always wins |
| Linux (Render) | @sparticuz/chromium, which ships its own binary so there is no browser download at build time |
| macOS / dev | whatever playwright-core already has cached (npx playwright-core install chromium) |
--single-process is deliberately not used. It is incompatible with reusing
one browser: closing a BrowserContext under that flag tears down the whole
browser, so the second render fails with "Target page, context or browser has
been closed". Measured on this codebase: 1 of 3 contexts survive with it, 3 of 3
without. Reuse is the more valuable half of the trade. Set
CHROMIUM_SINGLE_PROCESS=1 to force it back on if a host ever demands it, and
expect one render per launch.
Error handling
| Code | Meaning |
|---|---|
bad_input |
Arguments failed validation. |
duplicate_topic |
The topic already exists. Working as designed — pick another. |
not_found |
No posts row for that history_id. Was reserve_topic called? |
db_error / storage_error |
Supabase said no. details carries the Postgres code. |
image_generation_failed / image_too_large |
Provider or sharp problem. |
image_url_unreachable |
The URL Meta would fetch is not a reachable JPEG. |
caption_too_long |
Caption body alone exceeds 2200 chars. Hashtags are trimmed automatically; the body never is. |
meta_error |
Graph API. details has code, error_subcode, type, fbtrace_id, unchanged. |
timeout |
Something exceeded its budget (image 60s, container poll 60s, Graph 30s). |
config_error |
A required environment variable is missing. retryable: false. |
Meta codes 190 and 200 are never retried. 190 is an expired or invalid
token, 200 is a missing permission; both need a human, and retrying only burns
rate limit while hiding the real cause. Those errors come back with
retryable: false and a needs_human note saying what to do.
Every tool call logs tool.start and tool.ok/tool.error with a duration, and
every Graph call logs graph.call with method, endpoint, status and elapsed ms —
so Render's log viewer is enough to reconstruct a run.
Troubleshooting
| Symptom | Cause |
|---|---|
401 on every request |
Header missing, or the token does not match MCP_AUTH_TOKEN. |
500 config_error on /mcp, /health fine |
MCP_AUTH_TOKEN is not set on the service. |
image_url_unreachable |
The post-images bucket is not public, or the upload silently failed. Run npm run smoke. |
IG container stuck at IN_PROGRESS for 60s |
Meta cannot fetch the image, or it is being slow. Check the URL in a browser first. |
meta_error code 190 |
Token expired. Mint a new long-lived Page token. check_token() warns at ≤7 days. |
meta_error code 200 |
Scope missing — re-grant instagram_content_publish / pages_manage_posts. |
duplicate_topic |
Not a bug. The topic is already in posts. |
| First call of the day times out | Free-plan cold start. Hit /health first, or move to starter. |
| Worker dies mid-render, no error | Out of memory. Chromium needs ~400 MB; the free instance is 512 MB. Move to starter. |
bad_input naming a field |
Working as intended. Shorten the named field and call render_post again — nothing was uploaded. |
Self-hosted fonts failed to load |
src/templates/fonts/ did not reach dist/. Re-run npm run build; the render is refused rather than shipped in a fallback serif. |
Target page, context or browser has been closed |
CHROMIUM_SINGLE_PROCESS=1 is set. That flag allows only one render per launch — unset it. |
| Template file not found on Render | npm run build was skipped, so dist/templates/ is missing. |
Layout
src/
server.ts Express, bearer auth, /health, POST /mcp
config.ts Lazy env resolution, constants
log.ts Timed stdout logging
errors.ts AppError / MetaError, the no-retry rule for 190 & 200
supabase.ts posts CRUD + storage upload
meta.ts Graph client, IG container flow, FB photos, debug_token
image.ts Chromium lifecycle, template render, overflow guard, sharp
tools/
register.ts Timing, error envelope, content-block shaping
get_past_topics.ts reserve_topic.ts render_post.ts
publish_socials.ts mark_draft.ts check_token.ts
index.ts
templates/
tokens.css base.css design tokens + shared skeleton
news.html subject.html career.html
fonts/ barlow-400/600/700, cinzel-600 (woff2, self-hosted)
assets/ logo.png (supplied), logo-mark.png (derived)
migrations/001_init.sql
scripts/
smoke.ts render all three, upload, prove both guards fire
copy-templates.mjs build step: tsc emits only .ts, templates must reach dist/
prepare-logo.mjs derives logo-mark.png from logo.png
render.yaml
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.