← Back to Blog
From Menu Text to Auto-Posting: Building a Facebook Page Engine with Claude, a Local LLM and the Graph API
Linux & Open Source•Oct 07, 2026• 31 min read

From Menu Text to Auto-Posting: Building a Facebook Page Engine with Claude, a Local LLM and the Graph API

In one long evening I went from a messy text menu to a system that builds every menu image, photo card and caption from one data file, checks them, and posts to a Facebook Page after I type yes. This is the full tutorial, including every place I got stuck and what fixed it.

Why I built this

In one long evening I went from a messy text menu to a system that builds every menu image, photo card and caption from one data file, checks them, and posts to a Facebook Page after I type yes. I'm a Laravel developer in Dhaka, and this started as a favour: helping a home kitchen page called Dhaka Sweets get its menu online.

The menu was in Bangla, which added one problem an English menu never has: AI image tools and ordinary fonts cannot draw the script properly. That is not just a Bangla problem. Anyone posting in their native language, whether Hindi, Arabic, Thai, Tamil or another non-Latin script, hits the same wall, and the fix is the same: a proper font for that language, with text shaping. I explain it where it comes up, and the tutorial at the end uses an English menu with a short note on what to change for yours.

It was not smooth. I hit wrong permissions, empty API results, broken pastes, a token I exposed in a screenshot, and an AI that confidently made up a price. This post tells the full story, including every place I got stuck and what fixed it, and ends with a step-by-step tutorial so you can build your own.

What the system does today:

  • Edit one YAML file (prices, items, notes) and every image and caption rebuilds.
  • A check refuses to pass if any price, rule or layout is wrong.
  • A local LLM suggests subtitles, hashtags and opening lines, and I pick.
  • One command posts a card or the full menu to the Page, after a preview and my explicit yes.

The stack

The rule that shaped everything: AI plans and drafts, plain code does anything that must be exact.

Part Tool Job
Planning, research, reviews Claude (chat) Market research, menu wording, specs, checking every screenshot
Building Claude Code (terminal) Wrote and tested the engine, the hook and the skill
Local text model Qwen3.5-9B (Q6_K) on llama.cpp Suggestions only: subtitles, hashtags, opening lines
Local image model Z-Image Turbo in ComfyUI Food background photos, no text
Text on images Python Pillow + libraqm Correct shaping for any complex script, exact layout
Posting Meta Graph API v26.0 Photo and multi-photo posts to the Page
Hardware RTX 3060, 12 GB VRAM Runs both local models, never at the same time

Part 1: Pricing research and the menu text

I started with a plain-text menu in a Facebook post and asked Claude whether the prices made sense for home delivery in Dhaka. It searched current Foodpanda menus and raw meat prices, then compared item by item.

What changed:

  • Beef curry went up (1 kg of raw beef plus spices barely left a margin), while beef and mutton biryani came down because restaurants nearby sell them cheaper.
  • Luchi was priced above paratha, which is backwards, so it dropped to 15 taka.
  • Chicken curry was repriced after I said we use colour bird chicken, which costs much more than broiler.
  • Vague lines like fish price depends on the fish became a range, because Facebook customers skip unclear prices.

We also added rules that turned out to matter later: portion sizes in raw meat weight, delivery fees by area with a cheaper fee over 1,000 taka, side dishes only with a main course, and an allergy note on all fish curries.

The first AI mistake happened here. When I sent photos of red amaranth (lal shak), Claude filled in a price I never gave. I caught it, and from then on every price had to come from me. That rule later became a hard check in the engine.

Part 2: Menu images, and the native-font problem

AI image models cannot reliably write non-Latin scripts. Ask for Bangla, Hindi, Arabic or Thai text and most of them garble it or invent letters that look plausible to anyone who can't read them, and a menu with wrong prices is worse than no menu. So I split the job in two.

  1. Background by AI, no text. Z-Image Turbo ran in ComfyUI on my RTX 3060 at 8 steps, about 25 seconds per image (I wrote about setting up that local image studio earlier). The prompt asked for a top-down spread of the kitchen's food with an empty dark centre and ended with no text, no letters, no logos. I generated a few rounds and picked one.
  2. Text by code, with a proper native font. Claude Code drew the menu with Pillow, using Bangla fonts (Noto Sans Bengali and Hind Siliguri) and libraqm for text shaping. Many scripts merge letters into one shape (a conjunct like ক্ষ in Bangla or क्ष in Hindi), join them (Arabic) or stack marks above and below (Thai). With the wrong font or no shaping, these render as boxes or broken pieces, and every native reader sees it instantly.

The menu became three square 1080×1080 images for a Facebook carousel: page 1 for bhorta and chicken, page 2 for beef, mutton, fish and biryani, page 3 for roti, delivery and contact.

Reviewing them caught small but real problems: the carousel order was reversed, small grey notes were unreadable over busy food, and the phone number appeared only on the last image. Fixes: a footer on every page except the last, brighter notes, a darker panel, and page numbers in the menu's own digits (১/৩ in Bangla).

Part 3: Real photo cards, and the drift problem

For individual dishes I used real photos instead of AI images: chapa shutki bhorta, pabda fish chorchori and lal shak. Real food photos build more trust than generated ones. Claude Code only cropped, lightly colour-corrected and added the title, logo and contact line, in a square and a 4:5 portrait version.

Then the requests kept coming, as they do in a real business:

  • Remove prices from the photo cards but keep them in the captions.
  • Label bhorta and bhaji as side dishes that need a main course.
  • Add an allergy note to every fish curry.
  • Keep the main menu generic (just fish curry) and give named fish their own cards.

Each change was a new prompt to Claude Code, and the outputs started to drift. The clearest example: I asked to remove the pabda line from menu page 2, and the image came back unchanged. The terminal had regenerated from stale data. Text menu, captions and images no longer agreed, and I only noticed by looking.

That was the signal to stop prompting changes one by one and build a proper engine.

Part 4: The engine, one data file and one command

The engine (ds, a small Python CLI) generates everything from data, so text and images can never disagree. Claude wrote the spec in chat; Claude Code built it in stages and stopped after each for my OK.

How it fits together:

  1. data/menu.yaml holds every item and price, and is the only place a price may exist. Dishes sold only through photo cards sit in a separate card_only list that the menu never shows.
  2. data/cards.yaml describes each photo card: photos, title, notes and an approved description I wrote in the menu's language. Price and side-dish status come from the menu item it points to.
  3. ds build renders the 3 menu pages, 6 cards, the cover and all captions into output/.
  4. ds check runs eight checks: schema, prices re-derived from data, no card-only dish on the menu, side-dish and allergy rules, no overlapping text, text shaping available, freshness, and a golden comparison.
  5. ds diff compares every image with an approved reference copy.

The first milestone was strict on purpose: rebuild the existing approved images through the new code and get them byte-identical. All 10 matched by MD5, and the captions matched my golden text files byte for byte. Only then did any behaviour change.

Claude Code also broke the checks on purpose to prove they work: a price typed into a card note, a card-only dish added to the menu, and a stale caption after a price change. All three were caught.

A growth test found three real bugs before they hurt me: rows could overlap when a page overflowed, the overflow hint pointed at a page that was already full, and a 4th menu page would have crashed. Pages now come from the data, and the contact block always moves to the last page.

Part 5: Where the local LLM helps, and where it doesn't

I wanted to use local AI as much as possible. The honest result: a 9B model is fast and useful for suggestions, but not trustworthy for customer-facing text in a language with less training data than English.

Another lesson came first. Claude suggested installing Ollama without checking what I already had. I already ran Qwen3.5-9B on llama.cpp, managed by another tool of mine, with an OpenAI-style API on localhost. So I had Claude Code inspect the system before writing any AI code: runner, endpoint, VRAM, vision support (none).

A test in the menu's language settled the model's role. Asked for two Bangla sentences about lal shak (red amaranth), it answered in about 2 seconds warm and 25 seconds cold, at roughly 41 tokens per second. The sentences read fluently but contained made-up words such as গোলকপাতের and স্প্রিঞ্জলিংয়ে that no Bangla speaker would recognise. A customer would notice. Expect the same from small models in most languages other than English, and test yours before trusting it.

So the model's job is narrow:

  • ds suggest-subtitle, ds suggest-hashtags and ds suggest-intro offer options, and nothing is written until I run ds pick with a number.
  • Digits in any script and the currency word are stripped from every answer, so it can never introduce a price.
  • The description of each dish is written or approved by a native speaker once and reused.
  • If the model server is down, every other command still works.

GPU sharing needed care too. Qwen holds about 7.7 GB when awake and the image model needs much more, so both cannot load on a 12 GB card. The engine takes a lock file, waits for Qwen's 600-second idle sleep with a visible countdown, and frees ComfyUI's VRAM after each batch. It never stops the model server, because another tool owns it.

Part 6: A Claude Code skill and a hook

Two Claude Code features turned the engine into something I can drive with one sentence.

  • Skill (.claude/skills/dhaka-sweets/SKILL.md): describes the data files, the workflow and my hard rules. Never invent prices, side dishes need a main course, fish gets the allergy note, design stays unchanged. Now change beef biryani to 250 just works.
  • Hook (PostToolUse): after every edit or shell command, it asks ds status whether any output is stale. If nothing changed it stays silent. If something did, it runs ds build and ds check, and when a check fails it blocks Claude from carrying on until the failure is fixed. Mistakes are caught before I even look.

Both load only in Claude Code sessions started inside the project folder, which confused me at first.

One subtle bug is worth sharing. Freshness was first based on file timestamps. Restoring a backup with cp -p keeps the old timestamp, so nothing rebuilt and broken images stayed in place. The fix was a build manifest with the SHA-256 of every input per output. Now ds status finds stale outputs in 0.14 seconds, and restores are detected correctly.

Part 7: Posting with the Facebook Graph API

The code took minutes. Getting a working Page token took most of the evening. Here is the path that works, followed by every wall I hit.

The path that works

  1. At developers.facebook.com, create an app and pick the use case Manage everything on your Page (under Content management). Leave the app unpublished.
  2. In the use case, add exactly three permissions: pages_show_list, pages_read_engagement, pages_manage_posts.
  3. In Graph API Explorer, select the app, list the same three permissions, click Generate Access Token, and allow your Page in the popup.
  4. Turn that short-lived user token into a permanent Page token: exchange it for a long-lived user token with the app secret, then request the Page's access_token. A Page token made from a long-lived user token shows Expires: Never.
  5. Save the Page ID and Page token in .env with chmod 600, never in Git and never in chat.

The engine uploads JPEG copies (quality 92, 4:4:4 chroma, so small non-Latin text stays sharp at around 400 KB instead of 1.3 MB PNGs), attaches them to one post, and refuses to post without a passing ds check, a preview and the exact answer yes, typed in a real terminal. Typing YES in capitals is refused, which surprised me twice. On top of that, a config switch keeps live posting off entirely until I turn it on, so every early test was a dry run.

Where I got stuck

Symptom Cause Fix
Invalid Scopes: pages_read_user_content I removed the wrong permission; pages_manage_engagement pulls in one the app lacks Keep only the three permissions above
Object with ID ' me' does not exist Three spaces before me in the query Delete the spaces
me/accounts returned an empty list The token had no Page linked Ask for the Page directly: <page-id>?fields=access_token
Saved token was 10 characters long Ctrl+V doesn't paste in a Linux terminal Ctrl+Shift+V, or read the clipboard in a script
Not the Page token I copied the user token, not the Page token The script now accepts either and fetches the Page token itself
Script pasted line by line into the terminal I copied the script's text instead of running the file Save it with a heredoc, then bash setup-fb-env.sh
Two apps, secret from the wrong one I created a second app midway Token, app ID and secret must all come from the same app
(#200) permissions error on upload pages_manage_posts was missing from the Explorer list when the token was generated Re-add it, regenerate the token, rerun the script

The biggest mistake was mine: I took screenshots that showed full tokens, including a long-lived one. Anyone with that token can post as your Page. Cover the token box before any screenshot, and if one leaks, remove the app under Facebook Settings → Business integrations, which revokes its tokens.

What finally made it easy was a setup script. It reads the token from the clipboard or a hidden prompt, asks Facebook what the token is, swaps a user token for a permanent Page token, writes .env safely, clears the clipboard and runs a dry run. It never prints the token.

With that done, the first real post went out the same night: the lal shak card, posted by one command, logged with its post ID.

One caveat to check on your own first post: an app in Development mode may publish posts that only people with a role on the app can see. Open the post in a private window. If it isn't visible, the app needs to be switched to Live.

The guided tutorial: build your own in 12 steps

Everything above is the story. This part is the recipe. It builds a smaller version of my engine from an empty folder: one data file, menu images, a check that refuses bad data, optional local AI, posting to your own Facebook Page, and Claude Code wired in. The example menu is in English. If you post in another language, Step 2 has the one extra thing you need. Every command and every file below was run on my machine before I published it.

You can follow it with or without Claude Code. If you use Claude Code, paste each step in and ask it to do that step only, then look at the result before moving on.

What you need

  • Linux or macOS with Python 3.12 or newer (on Windows, use WSL).
  • A Facebook Page you are an admin of.
  • For the optional AI steps: an NVIDIA card with 12 GB of VRAM. Everything else runs on any laptop.
  • About two hours, most of it spent on the Facebook token.

Step 1: Create the project

mkdir menu-engine && cd menu-engine
python3 -m venv .venv
.venv/bin/pip install pillow pyyaml requests python-dotenv
mkdir -p fonts output
printf '.env\noutput/\n.venv/\n' > .gitignore

The .gitignore goes in first so that the token file can never be committed by accident.

Step 2: Get a font (and, for other languages, a native one)

Download a font for the images. The example uses Poppins, a free Google Font:

for w in Regular Bold; do
  curl -L -o fonts/Poppins-$w.ttf \
    https://github.com/google/fonts/raw/main/ofl/poppins/Poppins-$w.ttf
done

Posting in a language other than English? This is the problem that cost me the most time with Bangla, and it applies to Hindi, Arabic, Thai, Tamil and most other non-Latin scripts. Two things are needed:

  1. A proper font for your native script. A generic font either has no glyphs for your language, so you get empty boxes, or the wrong ones. Pick a font made for your script that also covers Latin, so prices and the phone number render too. Fonts I tested: Hind Siliguri for Bangla, Mukta for Hindi and other Devanagari languages, Hind Madurai for Tamil, Tajawal for Arabic and Sarabun for Thai, all free on Google Fonts. Watch for traps: Noto Sans Bengali has no Latin letters at all, and Hind draws Hindi digits in Western shapes.
  2. Text shaping. In these scripts, letters join, stack or change form depending on their neighbours. Without shaping, a Bangla conjunct like ক্ষ falls apart into separate letters and Arabic stops joining. Pillow does this through a library called raqm, which build.py already uses. Check that it is available:
.venv/bin/python -c "from PIL import features; print(features.check('raqm'))"

If it prints False, install fribidi (sudo apt install libfribidi0 on Debian or Ubuntu, sudo dnf install fribidi on Fedora, sudo zypper install fribidi on openSUSE, brew install fribidi on macOS) and run the check again until it prints True.

Then put your font's file names in font() in build.py, write your menu in your own language, and render a test page. Look closely at letters, digits and punctuation before you trust it.

Step 3: Put every price in one file

Create menu.yaml. This is the only place a price may ever exist. Images and captions are generated from it, so they cannot disagree with it.

shop: Home Kitchen
phone: +1 555 0100
currency: $
side_dish_note: Side dishes are sold only with a main course.
background: null            # or a path to an AI background with an empty dark centre

sections:

  • title: Sides page: 1 side_dish: true items:
    • {id: mashed-potato, name: Mashed potato, price: 4}
    • {id: roast-eggplant, name: Roast eggplant, price: 5}
    • {id: garden-salad, name: Garden salad, price: 6}
  • title: Chicken page: 1 items:
    • {id: chicken-curry, name: Chicken curry (half), price: 14}
    • {id: chicken-roast, name: Roast chicken (quarter), price: 12}
  • title: Beef and fish page: 2 items:
    • {id: beef-stew, name: Beef stew (500 g), price: 22}
    • {id: fish-curry, name: Fish curry, price: 18, note: Tell us if you have a fish allergy}

page decides which image a section goes on. Use your own phone number and currency symbol.

Step 4: Generate the images and caption

Create build.py. It draws one square 1080×1080 image per page, numbers the pages, puts the phone number on every page, writes the Facebook caption from the same data, and records a SHA-256 hash of its inputs so the check can tell when outputs are stale.

"""Build every menu page and the caption from menu.yaml. Run: python build.py"""
import hashlib, json
from pathlib import Path
import yaml
from PIL import Image, ImageDraw, ImageFont

SRC = [Path('menu.yaml'), Path('build.py')] OUT = Path('output') GOLD, WHITE = '#f2c14e', '#ffffff'

def load(): return yaml.safe_load(Path('menu.yaml').read_text(encoding='utf-8'))

def font(size, bold=False): name = 'Poppins-Bold.ttf' if bold else 'Poppins-Regular.ttf' return ImageFont.truetype(f'fonts/{name}', size, layout_engine=ImageFont.Layout.RAQM)

def price(data, p): return f"{data['currency']}{p}"

def pages(data): out = {} for s in data['sections']: out.setdefault(s['page'], []).append(s) return dict(sorted(out.items()))

def render_page(data, n, total, sections): bg = data.get('background') img = Image.open(bg).convert('RGB').resize((1080, 1080)) if bg else Image.new('RGB', (1080, 1080), '#3a2216') panel = Image.new('RGBA', img.size) ImageDraw.Draw(panel).rounded_rectangle((60, 60, 1020, 1020), 32, fill=(0, 0, 0, 175)) img = Image.alpha_composite(img.convert('RGBA'), panel).convert('RGB') d = ImageDraw.Draw(img) d.text((540, 100), data['shop'], font=font(60, True), fill=GOLD, anchor='mt') y = 210 for s in sections: d.text((110, y), s['title'], font=font(42, True), fill=GOLD) y += 66 for it in s['items']: d.text((130, y), it['name'], font=font(34), fill=WHITE) d.text((950, y), price(data, it['price']), font=font(34), fill=WHITE, anchor='ra') y += 54 if it.get('note'): d.text((150, y), '* ' + it['note'], font=font(24), fill='#d9d9d9') y += 38 if s.get('side_dish'): d.text((130, y), data['side_dish_note'], font=font(24), fill='#d9d9d9') y += 38 y += 24 if y > 930: raise SystemExit(f'page {n} overflows (y={y}): move a section to another page in menu.yaml') footer = f"{n}/{total} • WhatsApp {data['phone']}" d.text((540, 975), footer, font=font(28, True), fill=GOLD, anchor='mb') return img

def caption(data): lines = [data['shop'], ''] for s in data['sections']: lines.append(s['title']) for it in s['items']: lines.append(f"• {it['name']} – {price(data, it['price'])}" + (f" ({it['note']})" if it.get('note') else '')) if s.get('side_dish'): lines.append(data['side_dish_note']) lines.append('') lines.append(f"Order on WhatsApp: {data['phone']}") return '\n'.join(lines) + '\n'

def source_hash(): return hashlib.sha256(b''.join(p.read_bytes() for p in SRC)).hexdigest()

def main(): data = load() OUT.mkdir(exist_ok=True) for old in OUT.glob('menu-*.png'): old.unlink() ps = pages(data) for i, (n, sections) in enumerate(ps.items(), 1): render_page(data, i, len(ps), sections).save(OUT / f'menu-{i}.png') (OUT / 'caption.txt').write_text(caption(data), encoding='utf-8') (OUT / 'manifest.json').write_text(json.dumps({'source_sha256': source_hash(), 'pages': len(ps)})) print(f'built {len(ps)} page(s) + caption')

if name == 'main': main()

Run it and open the images in output/:

.venv/bin/python build.py

English does not need it, but keep the line layout_engine=ImageFont.Layout.RAQM: it is what makes the same code draw any other script correctly.

Step 5: Add a check that refuses bad data

Create check.py. It fails if a price is missing or zero, if a number sneaks into a note or title (prices live only in price), if a fish dish has no allergy note, if raqm is missing, or if the outputs are older than the data.

"""Refuse to pass if any price, rule or output is wrong. Run: python check.py"""
import json, re, sys
from pathlib import Path
from PIL import features
import build

fails = [] data = build.load() money = re.compile(r'\d|' + re.escape(data['currency'])) # \d matches digits in every script ids = set() for s in data['sections']: if money.search(s['title']): fails.append(f"section {s['title']}: a number or price in a title") for it in s['items']: if it['id'] in ids: fails.append(f"duplicate id {it['id']}") ids.add(it['id']) if not isinstance(it.get('price'), int) or it['price'] <= 0: fails.append(f"{it['id']}: price must be a whole number above 0") if money.search(it.get('note', '')): fails.append(f"{it['id']}: a number in a note (prices live only in price)") if 'fish' in it['id'] and not it.get('note'): fails.append(f"{it['id']}: fish needs the allergy note") if not features.check('raqm'): fails.append('Pillow has no raqm: non-Latin scripts will render broken (install fribidi)') man = Path('output/manifest.json') if not man.exists() or json.loads(man.read_text())['source_sha256'] != build.source_hash(): fails.append('outputs are stale: run python build.py') elif Path('output/caption.txt').read_text(encoding='utf-8') != build.caption(data): fails.append('caption.txt does not match menu.yaml')

for f in fails: print('FAIL', f) print('check: all passed' if not fails else f'{len(fails)} failure(s)') sys.exit(1 if fails else 0)

.venv/bin/python check.py

Now prove it works by breaking it. Change a fish note to include a price, or change a price without rebuilding, and run the check again. It must print FAIL. A check you have never seen fail has not been proven. Put the file back and rebuild before moving on.

Step 6 (optional): An AI background with no text

AI image models garble text, especially in non-Latin scripts, so the AI only makes the background and the code writes every word. I use ComfyUI with Z-Image Turbo on an RTX 3060; this post covers that setup. The model files are on Hugging Face under Comfy-Org/z_image_turbo (the bf16 diffusion model, the qwen_3_4b text encoder and ae VAE). My settings: 8 steps, cfg 1, sampler res_multistep, scheduler simple, shift 3, about 25 seconds per image.

The prompt pattern that worked for a menu background: top-down spread of home-cooked dishes on a dark wooden table, empty dark space in the centre for text, warm light, no text, no letters, no logos. Save the one you like as background.png, set background: background.png in menu.yaml, and rebuild. The dark panel in build.py keeps the text readable over busy food.

Step 7 (optional): A local model for suggestions only

A small local model is good at ideas and bad at facts. Mine invented Bangla words that do not exist, and small models are weaker in most languages other than English. So it only suggests, a human picks, and digits and currency symbols are stripped from everything it says so it can never introduce a price.

Run Qwen3.5-9B with llama.cpp (it downloads the model on first run and serves an OpenAI-style API):

llama-server -hf unsloth/Qwen3.5-9B-GGUF:Q6_K --port 8080 -ngl 99

Then create suggest.py:

"""Ask a local model for hashtag ideas. It suggests; you pick. Run: python suggest.py "dish name" """
import os, re, sys
import requests

URL = os.environ.get('LLM_URL', 'http://127.0.0.1:8080') + '/v1/chat/completions' NO_PRICES = re.compile(r'\d+|[$€£¥₹৳]') # digits in any script and currency symbols

dish = sys.argv[1] r = requests.post(URL, timeout=180, json={ 'messages': [{'role': 'user', 'content': f'Suggest 5 Facebook hashtags for a home kitchen post about {dish}. ' 'One per line, no numbering, no prices.'}], 'temperature': 0.7, 'max_tokens': 200, 'chat_template_kwargs': {'enable_thinking': False}}) r.raise_for_status() text = r.json()['choices'][0]['message']['content'] for i, line in enumerate([NO_PRICES.sub('', l).strip() for l in text.splitlines() if l.strip()][:5], 1): print(i, line)

.venv/bin/python suggest.py "roast chicken"

The first call after the model loads takes about 25 seconds on my card; later calls take a couple of seconds. On a 12 GB card, do not run this and ComfyUI at the same time.

Step 8: Create the Facebook app

  1. At developers.facebook.com, create an app and pick the use case Manage everything on your Page (under Content management). Leave the app unpublished.
  2. In the use case, add exactly three permissions: pages_show_list, pages_read_engagement, pages_manage_posts. Do not add others; some pull in extra permissions the app cannot have and the token request fails.
  3. Note the App ID and App secret from App settings, Basic. Everything below must come from this one app.
  4. Find your Page ID on your Page under About, Page transparency, or in the Page settings.

Step 9: Get a Page token that never expires

  1. Open Graph API Explorer, select your app, add the same three permissions, click Generate Access Token, and allow your Page in the popup. This is a short-lived user token.
  2. Copy it with the copy button. In a Linux terminal, paste with Ctrl+Shift+V, not Ctrl+V.
  3. Create get_token.py. It swaps the short-lived user token for a long-lived one, asks Facebook for the Page token, and saves it to .env with permissions 600. Secrets are typed at hidden prompts and never printed, not even in an error message.
"""Turn a short-lived user token from Graph API Explorer into a Page token that never expires,
and save it to .env. Nothing secret is printed. Run: python get_token.py"""
import getpass, os
from pathlib import Path
import requests

API = 'https://graph.facebook.com/v26.0'

def get(path, **params): try: # never let an error message echo the secret URL r = requests.get(f'{API}/{path}', params=params, timeout=20).json() except requests.RequestException: raise SystemExit('Could not reach Facebook. Check your connection and try again.') if 'error' in r: raise SystemExit(f"Facebook said: {r['error']['message']}") return r

app_id = input('App ID: ').strip() secret = getpass.getpass('App secret (hidden): ').strip() user_token = getpass.getpass('User token from Graph API Explorer (hidden): ').strip() page_id = input('Page ID: ').strip()

long_user = get('oauth/access_token', grant_type='fb_exchange_token', client_id=app_id, client_secret=secret, fb_exchange_token=user_token)['access_token'] page_token = get(page_id, fields='access_token', access_token=long_user)['access_token'] name = get(page_id, fields='name', access_token=page_token)['name']

env = Path('.env') env.write_text(f'FB_PAGE_ID={page_id}\nFB_PAGE_TOKEN={page_token}\n') os.chmod(env, 0o600) print(f'Saved a Page token for {name} to .env (chmod 600, ending ...{page_token[-4:]})')

.venv/bin/python get_token.py

A Page token made from a long-lived user token does not expire. Paste it into the Access Token Debugger on the Facebook developer site if you want to see Expires: Never for yourself.

Treat this token like a password. Never put it in a screenshot or a chat, never commit it, and if it ever leaks, remove the app under your Facebook Settings, Business integrations, which revokes it.

Step 10: Post, with a dry run first

Create post.py. It runs the check first and refuses to continue if anything fails. It converts each page to JPEG at quality 92 with full colour resolution (4:4:4) so small text stays sharp, and shows you the images and the full caption. Without --live it stops there. With --live it asks you to type yes in a real terminal, then uploads each photo unpublished and creates one post with all of them attached.

"""Post every menu page as ONE Facebook post. Dry run unless you add --live.
Run: python post.py            (dry run: shows what would be sent)
     python post.py --live     (asks you to type yes first)"""
import json, os, subprocess, sys
from pathlib import Path
import requests
from dotenv import load_dotenv
from PIL import Image

API = 'https://graph.facebook.com/v26.0' live = '--live' in sys.argv

if subprocess.run([sys.executable, 'check.py']).returncode != 0: sys.exit('check failed: nothing posted')

pngs = sorted(Path('output').glob('menu-*.png')) caption = Path('output/caption.txt').read_text(encoding='utf-8') jpgs = [] for p in pngs: # JPEG q92 4:4:4 keeps Bangla text sharp, ~4x smaller j = p.with_suffix('.jpg') Image.open(p).convert('RGB').save(j, quality=92, subsampling=0) jpgs.append(j)

print(f'\n{len(jpgs)} image(s): ' + ', '.join(f'{j.name} ({j.stat().st_size // 1024} KB)' for j in jpgs)) print('--- caption ---\n' + caption + '---------------') if not live: print('Dry run: nothing sent. Add --live to post.') sys.exit()

load_dotenv() page, token = os.environ['FB_PAGE_ID'], os.environ['FB_PAGE_TOKEN'] if not sys.stdin.isatty() or input('Post this to your Page? Type yes: ').strip() != 'yes': sys.exit('Not posted.')

ids = [] for j in jpgs: # 1. upload each photo unpublished with open(j, 'rb') as f: r = requests.post(f'{API}/{page}/photos', data={'published': 'false', 'access_token': token}, files={'source': f}, timeout=120).json() if 'error' in r: sys.exit(f"Photo upload failed: {r['error']['message']}") ids.append(r['id'])

form = {'message': caption, 'access_token': token} # 2. one post with all photos attached for i, pid in enumerate(ids): form[f'attached_media[{i}]'] = json.dumps({'media_fbid': pid}) r = requests.post(f'{API}/{page}/feed', data=form, timeout=60).json() if 'error' in r: sys.exit(f"Post failed: {r['error']['message']}") print(f"Posted: https://www.facebook.com/{r['id']}")

.venv/bin/python post.py           # dry run: read the caption carefully
.venv/bin/python post.py --live    # type yes to post

Only lowercase yes counts. Anything else, including YES, cancels. After the first real post, open it in a private browser window. An app still in Development mode may publish posts that only people with a role on the app can see; if the post is not visible, switch the app to Live.

Step 11: Teach Claude Code your rules with a skill

If you use Claude Code, a skill gives it your rules every time you work in this folder. Create .claude/skills/menu/SKILL.md:

---
description: Change the menu, prices, captions or images. Use whenever the user asks to add, remove or reprice a dish, or to rebuild or post the menu.
---

Files

  • menu.yaml is the only place a price may exist. Edit it, never the images or output/.
  • build.py draws the pages and caption; check.py must pass before anything is posted.

Hard rules

  • Never invent a price. If the user did not give one, ask.
  • Side dishes are only sold with a main course.
  • Every fish dish carries the allergy note.
  • Do not change the design unless asked.

Workflow

  1. Edit menu.yaml.
  2. The hook rebuilds and checks. Fix every FAIL before continuing.
  3. To post, tell the user to run python post.py --live themselves and type yes.

Now change roast chicken to 13 is a complete instruction: Claude edits menu.yaml, not the image.

Step 12: Make Claude Code check its own work with a hook

A hook runs a command after every edit Claude makes. This one rebuilds and runs the check, and if the check fails, exit code 2 hands the failures back to Claude so it fixes them before carrying on. Create .claude/hooks/check.sh:

#!/usr/bin/env bash
# After Claude edits a file: rebuild, then check. Exit 2 shows the failures to Claude.
cd "$CLAUDE_PROJECT_DIR" || exit 0
.venv/bin/python build.py >/dev/null 2>&1
.venv/bin/python check.py >&2 || exit 2

Make it executable with chmod +x .claude/hooks/check.sh, then create .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check.sh" }]
      }
    ]
  }
}

Skills and hooks in .claude/ only load when you start Claude Code inside this project folder. If they seem to be ignored, check where you started it.

Your weekly routine

  1. Change a price or dish in menu.yaml (or ask Claude Code to).
  2. python build.py, then python check.py (the hook does both for you in Claude Code).
  3. Look at every image in output/. A check catches wrong data; only your eyes catch an ugly page.
  4. python post.py, read the caption, then python post.py --live and type yes.

From here, the same pattern grows into what I run today: photo cards for single dishes, a cover image, scheduled posts, and an approved-reference comparison so a design change can never slip through unnoticed.

Lessons learned

  1. Let AI draft, let code decide. Prices, layout and text rendering are code. AI suggests, and a human picks.
  2. One source of truth beats careful prompting. Drift stopped the day prices lived in one YAML file.
  3. Refactor to byte-identical first. Matching the approved images exactly proved the engine before any change.
  4. Test your checks by breaking them. A check that never failed hasn't been proven.
  5. Check the machine before adding tools. I nearly installed a second model runner I didn't need.
  6. Small local models are honest helpers, not writers, at least for Bangla today. Test yours in your own language.
  7. Treat tokens like passwords. No screenshots, no chat, .env with chmod 600, and revoke on doubt.
  8. Script the fiddly parts. Ten minutes writing a setup script saved an hour of copy-paste mistakes.

Sources

By Mahmud Farooque25 views