Configuration
Accounts and connections (database)
Accounts (the Instagram profiles and YouTube channels Bongsu publishes to) and connections (how it reaches each
platform) live in the database, per workspace. The dashboard doesn't manage every kind yet; add
them with SQL (pnpm exec wrangler d1 execute bongsu --remote --command "…" in apps/cloudflare):
-- An Instagram account posted through Instagram's own API, its token in a secret named IG_TOKEN_TRAVEL
-- (site/docs/instagram-graph.md: creating the Meta app and getting the token)
INSERT INTO connections (id, workspace_id, provider, kind, secret_name, created_at, updated_at)
VALUES ('ig_travel', 'default', 'instagram', 'oauth', 'IG_TOKEN_TRAVEL', unixepoch(), unixepoch());
INSERT INTO accounts (id, workspace_id, platform, handle, external_id, connection_id, capabilities, created_at, updated_at)
VALUES ('demo_travel', 'default', 'instagram', 'demo_travel', '<Instagram user id>', 'ig_travel',
'{"trialReels": true}', unixepoch(), unixepoch());
-- Or through Composio
INSERT INTO connections (id, workspace_id, provider, kind, external_ref, created_at, updated_at)
VALUES ('c_travel', 'default', 'composio', 'composio', '<Composio connected-account id>', unixepoch(), unixepoch());
INSERT INTO accounts (id, workspace_id, platform, handle, external_id, connection_id, capabilities, created_at, updated_at)
VALUES ('demo_running', 'default', 'instagram', 'demo_running', '<Instagram user id>', 'c_travel',
'{}', unixepoch(), unixepoch());
-- A YouTube channel for Shorts comments, its OAuth JSON kept in a secret named YT_MAIN_OAUTH
INSERT INTO connections (id, workspace_id, provider, kind, secret_name, created_at, updated_at)
VALUES ('c_main', 'default', 'youtube', 'oauth', 'YT_MAIN_OAUTH', unixepoch(), unixepoch());
INSERT INTO accounts (id, workspace_id, platform, handle, display_name, connection_id, created_at, updated_at)
VALUES ('main', 'default', 'youtube', 'main', 'Main channel', 'c_main', unixepoch(), unixepoch());
| Column | Meaning |
|---|---|
accounts.id |
the account's key in the plan (plan_items.account, shorts_comments.channel); for Instagram, use the username |
accounts.external_id |
the Instagram user id / the YouTube channel id |
accounts.capabilities |
what the platform allows, e.g. {"trialReels": true} (Instagram requires a follower minimum for Trial Reels) |
accounts.settings |
the owner's choices, e.g. the Trial Reels default (set in the dashboard's Accounts page) |
accounts.status |
active or disabled (disabled accounts get nothing published) |
connections.provider |
instagram (Instagram's own API, instagram-graph.md), composio, youtube |
connections.external_ref |
the Composio connected-account id |
connections.secret_name |
the secret holding the credentials (Instagram: the token; YouTube: {client_id, client_secret, refresh_token, token_uri}) |
connections.credentials |
or the credentials themselves, encrypted with ENCRYPTION_KEY (what the dashboard's Connect stores, and where renewed Instagram tokens go) |
connections.status |
ok, expiring (a renewal failed) or broken (nothing is posted with it) |
An Instagram account without a working connection and user id is skipped: nothing is queued for it.
Caption rules
The caption check (packages/core/src/formats/caption-lint.ts) judges every caption against its platform's rules:
Instagram's are a cap of 5 hashtags (a FAIL blocks approval), a first line that fits before the feed's fold at about
125 characters, and two language-bound heuristics (a concrete hook, one call to action) written for English, Spanish
and Korean. An admin can change them per account in the dashboard's Accounts page, under Caption checks:
Hashtags max (0–30), First line chars (50–500) and the Language checks switch. The values live in
accounts.settings.captionRules, e.g. {"hashtagMax": 10, "languageChecks": false}; a missing field means the
platform's default, and a field of the wrong type is ignored. POST /admin-api/accounts/:id with captionRules
changes only the fields sent (null puts one back to the default). The PWA, the CLI's approve and the agent API all
show the same checks.
Branding
One instance is one brand. The app's name and theme colour are read by the browser before anyone signs in (the install name, the tab title, the manifest, the browser bar), so they are inputs to the app build, not database settings. Nothing else changes: the design, the accent and the dashboard keep Bongsu's.
| Variable | Default | What |
|---|---|---|
BONGSU_APP_NAME |
Bongsu |
Install name, <title>, manifest name/short_name, the sign-in title and the Android hint. At most 40 characters |
BONGSU_THEME_COLOR |
#131f2a |
Manifest theme_color and background_color, and the theme-color meta. #rrggbb; anything else falls back to the default |
The icons are the seven files in packages/web/public: favicon.svg, favicon.ico, icon-192.png, icon-512.png,
icon-maskable-192.png, icon-maskable-512.png and apple-touch-icon.png. pnpm brand makes all of them from one
square SVG of your mark, so a fork rebrands in three steps:
- Set
BONGSU_APP_NAMEandBONGSU_THEME_COLOR(below, for Cloudflare or Docker). - Make the icons:
BONGSU_APP_NAME="Lighthouse" BONGSU_THEME_COLOR="#112233" pnpm brand your-mark.svg(from a subfolder,pnpm -w brand). The mark goes on the theme colour, inside the maskable icons' safe zone, and is used at tab sizes too. It needs Python 3 and the Cairo library (libcairo2on Debian and Ubuntu,brew install cairoon macOS); the Python packages install once into a cache folder, not the repository. Commit the seven files. - Build the app (or the Docker image) with the same two variables.
The app's build checks the seven files and stops with the name of any one that is missing or at the wrong size, so a forgotten file can't ship a broken install icon. You can also replace them by hand, same names and sizes (keep the mark inside the central 80% of the maskable ones).
The project site (site/, pnpm site) takes its brand files from BRAND_DIR (default docs/assets/brand): mark.svg,
mark-small.svg, lockup.svg, social-card.png, apple-touch-icon-180.png and favicon.ico.
What stays Bongsu's: the design's colours (tokens.css), which carry meaning (green means approved and running), and the
dashboard's mark.
- Cloudflare: set the variables in the environment of the app build:
BONGSU_APP_NAME="Lighthouse" BONGSU_THEME_COLOR="#112233" pnpm --filter @bongsu/web build(orpnpm build), and also in CI's variables if CI deploys. - Docker: put both in
.env(docker composepasses them as build arguments), ordocker build --build-arg BONGSU_APP_NAME="Lighthouse" --build-arg BONGSU_THEME_COLOR="#112233" .; rebuild the image to apply.
Variables (wrangler files)
| Name | Meaning |
|---|---|
DRY_RUN |
"1": log and notify instead of publishing. "0": live |
PUBLIC_URL |
the bongsu Worker's URL (signed media links, notification links) |
RP_ID |
passkey relying-party id: the app's hostname |
VAPID_PUBLIC_KEY, VAPID_SUBJECT |
Web Push |
JOBS_PER_TICK |
optional, on the cron Worker: publishing jobs advanced per run (default 4, at most 100). See How many go out at once |
YOUTUBE_UPLOAD_MODE |
optional, on the cron Worker: "chunk" sends YouTube uploads in 8 MiB pieces instead of streaming the file in one request. Leave it unset unless YouTube answers the upload with 400 or 411, or an upload fails on the CPU limit: watch the first real upload (see YouTube) |
INBOX_WORKSPACES_PER_RUN |
optional, on the cron Worker: workspaces whose inbox folder one run scans (default 3, at most 100); the next run carries on with the following ones. Set at deploy time, never in the dashboard |
Secrets
Set on both Workers with wrangler secret put (never in the wrangler files).
| Secret | Required | What |
|---|---|---|
COMPOSIO_CONSUMER_KEY |
for Composio connections | Composio consumer key (instagram-composio.md) |
MEDIA_SIGNING_KEY |
yes | random string; signs the media links Instagram downloads (openssl rand -hex 32) |
ENCRYPTION_KEY |
to connect from the dashboard and to set up a media store | a long random string (openssl rand -hex 32) that encrypts the OAuth apps' secrets, the media stores' keys and grants, and the credentials kept in the database |
SETUP_TOKEN |
to sign up (web Worker only) | a long random string; the first person signs up in the dashboard with it, once |
YouTube OAuth secrets (any name, e.g. YT_MAIN_OAUTH) |
no (channels connected without the dashboard) | JSON {client_id, client_secret, refresh_token, token_uri}, named by the channel's connection (secret_name) |
VAPID_PRIVATE_KEY |
for push | the private half of the VAPID pair |
RESEND_API_KEY |
with MAILER = "resend" |
a Resend API key: connection alerts are also emailed to the workspace's admins. Set MAILER = "resend" and MAIL_FROM (a sender on a domain verified in Resend, e.g. Bongsu <bongsu@example.com>) as [vars] in the cron Worker's wrangler file |
/health lists missing secrets by name: the ones every instance needs, plus those its connections and media stores
need. It also lists the media stores and their status, and isn't ok until at least one is set up and none is broken.
The media stores themselves (Google Drive, an S3-compatible bucket, the server's disk) aren't configured here: they're
set up in the dashboard's Media page and kept in the database, their keys and grants encrypted with
ENCRYPTION_KEY (Google Drive, S3-compatible buckets).
On Node and Docker
The same names, from the environment (.env with docker-compose; self-hosting.md), plus:
| Name | Default | Meaning |
|---|---|---|
MEDIA_DIR |
/media in Docker |
a directory this server offers as a media store ("This server's disk" on the dashboard's Media page; the bongsu-media volume), streamed through the signed /m/ links. Unset: not offered |
MAILER |
none | smtp (Node only) or resend |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS |
port 587 | the SMTP relay for MAILER=smtp (Mailpit in the compose file's mail profile: mailpit, 1025) |
MAIL_FROM |
the sender, e.g. Bongsu <bongsu@example.com> |
|
DATABASE_PATH |
/data/bongsu.db in Docker |
the SQLite file |
DATABASE_URL |
none | postgres://user:password@host:5432/db: Postgres instead of the SQLite file (databases.md) |
SCHEDULES |
on | off: serve only, run no schedules (no publishing, no polling of comments or the inbox): for a replica or a demo |
PORT, HOST |
8787, 0.0.0.0 |
where it listens |
MIGRATIONS_DIR, WEB_DIR |
set in the image | the folder holding sqlite/ and postgres/ migrations, and the built app |
RP_ID |
PUBLIC_URL's host |
the passkeys' relying party |
JOBS_PER_TICK |
20 |
publishing jobs advanced per run, at most 100 (How many go out at once) |
INBOX_WORKSPACES_PER_RUN |
20 |
workspaces whose inbox folder one run scans, at most 100 |
How many go out at once
Every 5 minutes the scheduler advances at most JOBS_PER_TICK publishing jobs, all accounts and workspaces together,
the oldest due first. A video takes a few runs (Instagram processes it, then Bongsu publishes it) and holds its place
until it's out, so items due at the same minute beyond that number go out over the next runs. Nothing is dropped,
but a job more than 6 hours late waits for you instead (CATCH_UP_S).
- Cloudflare: 4. Each job costs a few subrequests and the free plan allows 50 per invocation. On the paid plan
(1,000 subrequests) you can raise it: set
JOBS_PER_TICKin[vars]ofwrangler.cron.toml, the Worker that runs the schedules. - Node and Docker: 20. No such limit there; set
JOBS_PER_TICKin.envto change it.
Bongsu has no limit of its own per account. Instagram allows 100 posts per account per 24 hours through the API (Instagram); YouTube releases scheduled videos itself, so any number go public together.
The media stores' layout
The same in every store: a Drive's top folders, a bucket's key prefixes (under its folder, if it has one), the server's media directory.
| Path | Use |
|---|---|
<folder>/<project>/… |
media of plan items (looked up in the workspace's default store) |
<folder>/inbox/<project>/ |
drop files here to get drafts (every store's inbox is read) |
<folder>/_posted/ |
posted media is moved here, in the store it's in |
<folder>/_uploads/ |
files uploaded from the app, in the store picked for the upload |
<folder> is the workspace's media folder: a name made from the workspace's name (UPLOADS for a workspace kept from
before this release), editable by its admins in Settings → Media. Bongsu never moves files when it changes.
Tunable limits (packages/core/src/constants.ts)
| Constant | Default | Meaning |
|---|---|---|
CATCH_UP_S |
6 h | a job later than this waits for the owner |
MAX_POLLS |
24 | ticks to wait for an Instagram container |
LINK_TTL_S |
6 h | lifetime of a signed media link |
JOBS_PER_TICK |
4 | the default jobs advanced per run on Cloudflare (free plan: 50 subrequests per invocation); Node's is NODE_JOBS_PER_TICK (20), and the JOBS_PER_TICK variable overrides both, up to JOBS_PER_TICK_MAX (100) |
AUDIT_KEEP_S |
180 days | audit log retention |
METRICS_DENSE_S |
6 h | time between a post's results samples while it is young… |
METRICS_DENSE_UNTIL_S |
3 days | …until this age |
METRICS_DAILY_S |
24 h | then a sample this often… |
METRICS_DAILY_UNTIL_S |
30 days | …until this age |
METRICS_WEEKLY_S |
7 days | then a sample this often… |
METRICS_STOP_S |
90 days | …until this age; after it no more samples |
METRICS_PER_RUN |
4 | samples taken per inbox cron run, oldest due first |
METRICS_ATTEMPTS_PER_RUN |
8 | samples tried per run, failures included (a failed post waits one interval; an account's first failure ends its turn for the run) |
METRICS_REFRESH_PER_RUN |
2 | YouTube upload lists the sampler refreshes per run once an hour old, the oldest first |
ACCOUNT_METRICS_S |
24 h | how often each account's follower count is sampled |
ACCOUNTS_PER_RUN |
4 | accounts sampled per inbox run at most |
ACCOUNTS_ATTEMPTS_PER_RUN |
8 | accounts tried per run, failures included (a failing account waits 6 hours) |
ACCOUNT_BACKFILL_PER_RUN |
200 | snapshot samples the one-time backfill adds per run (a long history takes a few runs) |
METRICS_MAX_POINTS |
400 | points a metrics read answers at most (the newest) |
They are constants, not environment variables: change one and redeploy.
Time zones and digest
The database stores every time in UTC (unix seconds: plan_items.due_at, approvals.due_at, posted.published_at,
jobs.due_at). Time zones only apply when a person or an agent enters or reads a time:
- The workspace's zone (an IANA name such as
Asia/SeoulorEurope/Madrid) is the default for everyone in it, and the zone of its agent keys, notifications and daily digest. The digest goes out once a day from the workspace's digest hour. - A user's own zone (
users.timezone, NULL = the workspace's) changes only what that user sees and enters in the app: Settings → Time zone. Nothing planned moves when either zone changes.
"18:00" entered in a zone with daylight saving is 18:00 on that day's clock, before or after the change. A new instance
starts on UTC at 08:00; change the workspace's zone with SQL until the dashboard has a setting:
UPDATE workspace_settings SET timezone = 'Europe/Madrid', digest_hour = 8, updated_at = unixepoch()
WHERE workspace_id = 'default';