weone-daily-post
by Weone404
README.md
# weone-daily-post — remote MCP server
Publishing backend for the We One Aviation daily Instagram + Facebook post.
It is a stateless [Streamable HTTP](https://modelcontextprotocol.io) 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:
1. **Length limits**, checked before Chromium is touched — the cheap rejection.
`points[2] is 97 characters, limit is 90. Shorten it and retry.`
2. **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
0. `HEAD` the `image_url` and assert `200` + `content-type: image/jpeg`. Meta
fetches this URL server-side, and a bad URL there fails opaquely hours later.
(A store that rejects `HEAD` gets a one-byte ranged `GET` instead.)
1. **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 with
`caption_too_long` before anything is posted.
`POST {IG_USER_ID}/media` → poll `GET {container}?fields=status_code,status`
once a second for up to 60s → publish only on `FINISHED`. On `ERROR` the
`status` string is returned verbatim, because it is the only place Meta
explains what it disliked.
2. **Facebook** — `POST {FB_PAGE_ID}/photos` with `url` and `message`. Attempted
regardless of the Instagram outcome.
3. **Record** — the `posts` row gets `ig_post_id`, `fb_post_id`, `image_url`, and
`status` = `published` (both), `partial` (one) or `failed` (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](#browser-lifecycle-and-memory). |
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 `posts` table with the check constraints and the **unique index on
`topic`** — that index is the repetition guard, so a duplicate reserve is
*supposed* to fail,
- `created_at desc` and `status` indexes,
- RLS enabled on `posts` with no policies (only the service key gets in),
- the **public** `post-images` storage 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):**
1. Push this repo to GitHub.
2. Render dashboard → **New** → **Blueprint** → pick the repo. It reads
`render.yaml`: Node 20, `npm ci && npm run build`, `npm start`,
health check on `/health`.
3. Render prompts for every `sync: false` variable. Paste them.
4. Deploy, then check the logs for `server.listening ... auth=configured`.
`auth=MISSING` means `MCP_AUTH_TOKEN` did not get set.
**Manually:**
1. **New** → **Web Service** → connect the repo.
2. Runtime **Node**, build `npm ci && npm run build`, start `npm start`.
3. Health check path `/health`.
4. Add the environment variables from the table above, plus `NODE_VERSION=20`.
Verify:
```bash
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:
```bash
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):
```json
{
"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
```bash
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`
```bash
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.css` holds every colour;
`base.css` holds 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 on
`document.fonts.ready` and then asserts both faces actually loaded rather than
screenshotting a fallback.
- **No user text is ever concatenated into markup.** Values go in through
`textContent` and `createElement`, 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. `cacheControl` is 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 with
> `overflow: hidden`. Without it the absolutely-positioned graphic hangs past the
> bottom edge, counts toward `body.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`:
```bash
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
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues