Horns
Responsibility
Section titled “Responsibility”Horns is the home for Foxy’s Cloudflare-managed apps — Workers (and, when needed, Pages). Everything else in the beef monorepo deploys on Railway; everything under apps/horns/ runs on Cloudflare’s edge via wrangler. It exists for workloads that fit Cloudflare’s model better than an always-on Railway service: cron-scheduled jobs, lightweight HTTP endpoints, and edge-hosted static sites — with no long-running container to pay for or operate.
Horns is a container, not a single service: each subdirectory is one self-contained Worker/Pages app with its own wrangler.toml, package.json, lockfile, src/, and test/. There is no shared build infrastructure and (deliberately) no shared package yet — each app deploys independently.
How it works
Section titled “How it works”- Runtime. Workers run on Cloudflare’s V8 isolates, not Node — code targets the Workers runtime (
fetchandscheduledhandlers), bundled and deployed bywrangler. No Railway service. Where an isolate can’t do the job (gifgen’s ffmpeg), an app may attach a Cloudflare Container sidecar (its own Dockerfile, built and pushed bywrangler deploy) behind a Durable Object binding. - Scheduling. Cron triggers are declared in each app’s
wrangler.tomlunder[triggers] crons. Cloudflare invokes the app’sscheduled()handler once per matching cron expression, and the app routes oncontroller.cron. Gotcha: Cloudflare cron weekdays are1=Sun..7=Sat(not the Unix0=Sun..6=Sat), so a bare1means Sunday. Use the three-letter abbreviation (MON,FRI, …) for any day-of-week field to keep it unambiguous. - State. Durable state uses Cloudflare bindings — KV, D1, R2, Durable Objects — declared in
wrangler.toml. Binding names are committed; the namespace ids are created per-account withwranglerand pasted in. - Secrets. API keys and tokens are set with
wrangler secret putand are never committed. Localwrangler devreads a gitignored.dev.varsinstead. - Config and safety. Non-secret runtime flags live in
wrangler.tomlunder[vars]. By convention an app ships with its side effects (posting, writing, sending) gated off — for example aDRY_RUN="1"flag — so a fresh deploy never acts until someone explicitly enables it.
linear-reports
Section titled “linear-reports”The first Horns app: a scheduled Linear → Slack reporting framework. Since the 2026-10-05 Ways of Working, #project-management carries only due-date changes and the weekly reports. Every report covers the FOXY team and all of its sub-teams (DISCO, PAY, ENGINE, and so on). One cached Linear read feeds the two weekly reports. The due-date watcher reads Linear directly, because a 30-minute cache is too stale for its 20-minute debounce:
- weekly-load: the weekly points report. It opens with three summary lines: points done this week with the trend, how root issues done this week met their due dates (on time, including after a date change; late; no due date), and committed work in flight. Then each team, most points done first, lists one
done · in flightline per person with their biggest item in that team linked. A Needs attention section closes it, listing only overloaded people, idle people, and estimate-hygiene flags (batch-closed aged work; big estimates with little in-progress time). Details in the heuristics’ doc comments (src/heuristics/points.ts). Runs Mondays 07:00 UTC. - workflow-hygiene: reports stale
Review/QAwork, in-flight root issues without a due date (tiny work excepted), past due dates, missing owners or estimates, mid-flight scope additions from the last 7 days, blocked work, and expedited work. Only checks that found something get a section; the rest fold into one “All clear” line. Runs Mondays at 07:00 UTC. - due-date-change: posts one message per due-date change on a root issue (moved later, moved earlier, or removed), once the date has been unchanged for 20 minutes, with who changed it, the assignee, the status, and the changer’s one-line reason from the issue’s comments. Setting a first date does not post. Polls every 15 minutes and keeps a processed-up-to cursor in KV.
The daily Triage alert, daily scope-creep alert, and the Current Priorities document watcher were retired on 2026-10-05.
Linear access is read-only; Slack is the only writer. Adding a workflow is a new file plus one registry line and one cron — the scheduler, data layer, and Slack delivery never change. Its architecture, workflows, and per-app setup live in apps/horns/linear-reports/README.md.
gifgen
Section titled “gifgen”The Slack /gifgen <prompt> command (and @Horny <prompt> mentions, which are the thread entry point — Slack refuses developer slash commands in threads): generates a short AI clip via OpenRouter’s async video API, converts it to a looping GIF, and shows it inline as an ephemeral preview with Post / Retry / Cancel. Post shares the GIF into the channel or thread through Slack’s external upload flow.
Architecture in one line: slash/mention handler verifies the Slack signature and acks in under 3 seconds → Cloudflare Queue (horns-jobs) → consumer generates through OpenRouter (model + measured latency/cost table in wrangler.toml), stores MP4 + GIF in the shared horns R2 bucket under the gifgen/ prefix, and delivers the preview. MP4→GIF runs in an ffmpeg Cloudflare Container reached via a Durable Object binding — the first horns app to use a container — and degrades to an MP4-link preview when conversion fails. Button state rides in Block Kit action values; there is no database. Setup, secrets, Slack app manifest, and test tiers live in apps/horns/gifgen/README.md.
Deploying
Section titled “Deploying”Each app deploys on its own with wrangler, run from its own directory. The standard procedure — an app’s README fills in the specifics (binding names, the secret list, rollout order):
- Authenticate —
wrangler login(or exportCLOUDFLARE_API_TOKEN). One-time per machine, run by the deployer. - Provision state — create any KV/D1/R2 namespaces (
wrangler kv namespace create <BINDING>, plus--preview) and paste the returned ids intowrangler.toml. - Set secrets —
wrangler secret put <NAME>for each key or token. Never commit them. - Validate dry-run first — exercise the app with side effects gated off (
DRY_RUN="1") and read the output before enabling anything. - Deploy —
wrangler deploy(or the app’spnpm deploy). A scheduled app registers its cron triggers on deploy; the schedule stays dormant until then. - Go live deliberately — flip the gate (
DRY_RUN="0") and redeploy, enabling one trigger at a time while watchingwrangler tail. - Rollback — re-gate (
DRY_RUN="1") or drop thecronsand redeploy, orwrangler rollbackto the previous version.
The repo-level guideline lives in apps/horns/README.md; each app documents its concrete bindings, secrets, and rollout order in its own README.
Conventions
Section titled “Conventions”- One directory per app, fully self-contained (its own
wrangler.toml,package.json, lockfile). - No shared package until a second app needs to reuse code (for example the Linear/Slack clients) — extract a
packages/workspace then, not before. - Ship gated off. Side effects default to disabled so a deploy is safe; turning them on is an explicit, observable step.
- Secrets in
wrangler secret, never inwrangler.toml.