The One-Way Mirror Pattern
A Worker cron as the sole writer of a Slack List, with every human on read-only access
Every page in this section builds toward one design: a Cloudflare Worker cron mirrors rows from an external database into a Slack List, a bot token is the only writer, and every human who can see the list has read-only access. This page is the assembled pattern — the setup mechanics, the rate math, and what "read-only" does and does not actually buy you.
The external database (or source table) stays the system of record for the whole pattern's lifetime. The list is a pushed view over it, never the other way around — everything in Item Caps and Auto-Archive exists because a list cannot safely hold a full, ever-growing history.
Mechanics checklist
1. How the bot gets write access: two real routes, and one dead end
Route A — the bot creates the list itself (the simpler default). Have the bot call slackLists.create rather than having a human create the list and share it with the bot afterward. This resolves two problems at once:
You choose the option slugs. Neutral ASCII values (e.g.
todo/doing/done) under whatever display label you want — the value/label mismatch problem (chasing opaqueOpt…-style slugs a human assigned) disappears entirely.Access is implicit for the creator. A creator has access to what it created, with no extra grant call required.
Route B — the operator owns the List and grants the bot EDIT access through the List's own Share UI. This route is production-exercised: the operator creates and configures the List by hand — views, board layout, group-by, and localized labels all stay under human ownership, see Permissions and Ownership for the full ownership-topology tradeoffs — then opens that specific List's Share dialog and grants the bot's user edit access directly. This grant is a distinct mechanism from both slackLists.access.set (the API route — its user_ids / channel_ids arguments have no app_ids equivalent for handing a bot access to a list it did not create) and from sharing the List into a channel the bot happens to belong to. State this plainly: a List being shared into a channel that contains the bot proves NOTHING about the bot's edit access to that List — channel membership and the per-List Share grant are separate permission surfaces, and conflating them is the most common way this setup silently fails. Scopes alone are insufficient either way: a bot holding lists:write that was never granted through the Share UI still gets list_not_found or an access error the moment it calls items.create / items.update against that List.
Corollary for Route B: because the operator, not the bot, chose every column and option slug when creating the List, the bot must resolve every column ID and option ID at runtime from items.info — anchored on any known row, per Reading Lists — and register them in its own configuration, never assuming a slug it would have picked itself at create time. This is the direct cost of not being the creator, and the mirror image of Route A's "you choose the option slugs" property.
Keep Route A as the simpler default. Whichever route you use, mandate a live probe with the real bot token before enabling row writes — one throwaway items.create / items.update call against the real list, confirming an ok: true response — and keep row registration disabled in configuration until that probe passes. Route A's access is implicit and lower-risk; Route B's Share grant is a manual UI action a human can get wrong, so the probe is what actually confirms it before the cron starts writing. See The Activation-Gate Probe for the full reversible probe sequence, its output hygiene rules, and exactly which claims a PASS retires. The rest of this checklist (steps 2–7) is written for Route A's defaults — on Route B, substitute the operator-supplied list_id and the runtime-resolved column_ids wherever a step assumes the slackLists.create response.
2. Grant read access to the channel
One slackLists.access.set call, access_level: "read", channel_ids pointing at a private channel — see Permissions and Ownership for why private (not public) matters, and for the "Only you can share" setting to enable right after.
3. Persist list_id, every column_id, and every row_id
Nothing here is rediscoverable without an extra call — see Reading Lists for how to recover this mapping if it is ever lost: any known row anchors an items.info schema/limits read, and a one-time sentinel row is only a bootstrap-only fallback for before any real row exists — it gets evicted once real rows arrive:
list_idand each writtencolumn_id, from theslackLists.createresponse.row_idper mirrored source row, fromslackLists.items.create— persisted to your own database atomically with the create call, per No Events, No Idempotency.
4. Cron tick: upsert by stored row id
Every source row you sync branches on whether it already carries a stored row_id — items.create if not, items.update if so. This is the core idempotency mechanism covered in full in No Events, No Idempotency; this pattern page assumes it and moves on to what wraps around it.
5. Freshness stamp via description_blocks
At the end of each successful sync, one slackLists.update call rewrites the list's description to something like Synced from <source> · 2026-08-06 14:32 · 187 rows. This costs one call at Tier 2, touches no rows, and has zero cap impact — a far cheaper freshness signal than a per-row "last synced" column, which would cost N writes per sync and make every row look changed to anyone diffing updated_by.
// NOTE: the argument is `id`, not `list_id` — the one method in the
// 12-method surface that names this argument differently from every
// items.* method, which all use `list_id`.
export async function stampFreshness(
botToken: string,
listId: string,
sourceName: string,
rowCount: number,
): Promise<void> {
const timestamp = new Date().toISOString();
const res = await fetch("https://slack.com/api/slackLists.update", {
method: "POST",
headers: {
authorization: `Bearer ${botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({
id: listId,
description_blocks: [
{
type: "rich_text",
elements: [
{
type: "rich_text_section",
elements: [
{ type: "text", text: `Synced from ${sourceName} · ${timestamp} · ${rowCount} rows` },
],
},
],
},
],
}),
});
// Slack's Web API returns HTTP 200 even on failure — {"ok": false, "error": "..."}.
// This call is meant to run after every successful sync tick, so a swallowed
// rejection here would leave a stale stamp while the tick still reports success.
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) {
throw new Error(`slackLists.update rejected: ${body.error}`);
}
}6. Periodic reconcile, including the archived pass
An infrequent full pass — paginate items.list, diff against your database, then run the same pass again with archived: true. See No Events, No Idempotency for the full mechanics and why the second pass is the only way to see what auto-archive removed.
7. Self-cap with explicit deletes
Evict your own oldest rows with items.deleteMultiple well before the real per-list cap, rather than depending on Slack's reported (and unverified) auto-archive behavior. See Item Caps and Auto-Archive for the full policy and a copy-pasteable eviction helper.
The one manual step: board setup
Board layout, the group-by column, and the default view are UI-only and owner-configured — see Board Layout. A human does this once, by hand, using the ownership topology from Permissions and Ownership (the bot promotes a human to owner, or a human creates and configures the list and grants the bot write access instead). This is a one-time setup cost, not part of the cron tick.
Rate math for a mirror
Take a mirror self-capped at 300 active rows (see Item Caps and Auto-Archive for why to stay well under the 1,000-row ceiling).
| Operation | Frequency | Calls | Tier | Rough cost |
|---|---|---|---|---|
Initial backfill (300 rows, items.create) | once | 300 (one call per row) | 2 or 3 — contested, assume the stricter Tier 2 (20+/min) | ~15 min |
Steady-state update (worst case: one call per changed row, items.update) | per cron tick | up to 300 | Tier 3 (50+/min) | ~6 min for a full-batch resync |
Reconcile pass (items.list, paginated) | per cron tick or less often | ~3 (300 rows ÷ ~100/page) | Tier 2 (20+/min) | seconds |
Freshness stamp (slackLists.update) | once per successful sync | 1 | not stated in the source research | negligible |
Unverified
The steady-state row is a worst case on purpose. Because row_id lives inside each cells[]entry rather than at the top level of items.update, one call could structurally carry cells for many different rows in a single request — but every documented sample shows exactly one cell for one row, and no page states multi-row batching either works or is supported. This is the single highest-leverage unknown in the whole pattern: it is the difference between a ~5-second and a ~6-minute sync for a few hundred changed rows. This project's spike, which would have tested a real multi-row cells[] payload against a live list, was skipped. Test this empirically before finalizing a design that depends on batching, and regardless of the answer, guard the cron handler against overlapping its own next tick with a run lock or lease — seeNo Events, No Idempotency.
Separately, items.create's rate tier is itself contested between two Slack-official sources (docs say Tier 2, the Java SDK's machine-readable rate-limit metadata says Tier 3) — assume the stricter Tier 2 when sizing a backfill, as the table above does.
Rate limits inside a cron tick
"Honor Retry-After" is the standard advice for a rate-limited call, but a Worker cron tick cannot sleep for an arbitrary server-chosen duration — a tick has a fixed execution budget, and blocking it on Slack's clock either blows that budget or delays every other list the same tick was meant to service.
Deferral, not sleep, is the pattern. The moment one row in a batch comes back rate-limited, stop processing the rest of that batch immediately — do not keep working through the remaining rows at a slower pace. Compute next_attempt_at as the max of your own backoff ladder and the Retry-After value, and persist it in two places: on the specific work row that was rate-limited, and on the list's own registration record. The next cron tick checks the list-level timestamp first and skips the entire list until it passes — a single rate-limited call defers the whole list, not just the one row.
Reserve small, bounded in-call retries for interactive and probe paths only — a human waiting on a UI action, or the live-access probe from the section above — and only when Retry-After is short (roughly ≤60s) and capped at ~2 attempts. The scheduled cron path runs with zero in-call retries; it always defers instead.
Ordinary (non-rate-limit) failures get their own persisted backoff ladder — for example 1 minute → 5 minutes → 30 minutes — rather than the rate-limit deferral above. After N attempts on that ladder, dead-letter the row: give it a far-future sentinel next_attempt_at so it stops consuming tick budget, but leave it queryable and inspectable rather than silently dropping it.
What read-only does not give you
Granting read access is a real, server-enforced restriction — but it is not a silence guarantee, and it is not absolute:
Viewers can still read and post comments in item threads. A "read-only" board can still generate discussion; it just cannot be edited directly.
Owners and admins can delete the entire list at any time, regardless of what access level anyone else holds. No ACL configuration prevents an admin override.
A published Form workflow bypasses access levels entirely — publish none on a mirrored list.
See Permissions and Ownership for the full list of five holes and the ownership topologies this pattern depends on.
Setup checklist
Confirm the workspace is on a paid plan (Pro or above) and Lists has not been disabled by an admin.
Add
lists:readandlists:writescopes to the app and reinstall (adding scopes forces a reinstall).Pick an ownership topology (see Permissions and Ownership); exactly one human ends up holding write either way — record their name in your runbook.
Share the list into a private channel with
access_level: "read".Turn on "Only you can share" (Share → Advanced settings) so read access cannot spread beyond who you granted it to.
Publish no Form workflow on the list, and leave field-change notifications off until tested — whether API-originated writes (as opposed to human edits) trigger "notify when field changes" automations is undocumented; a cron rewriting cells every tick could firehose the channel if that turns out to be true.
Accept, going in, that admins can delete the board at any time and that viewers can comment in item threads — neither is a defect in your setup, both are how Lists work.
Related
The Activation-Gate Probe — the live probe this page's write-access routes both gate on before enabling row writes