Skip to content

Horns

Horns

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

  • Runtime. Workers run on Cloudflare’s V8 isolates, not Node — code targets the Workers runtime (fetch and scheduled handlers), bundled and deployed by wrangler. 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 by wrangler deploy) behind a Durable Object binding.
  • Scheduling. Cron triggers are declared in each app’s wrangler.toml under [triggers] crons. Cloudflare invokes the app’s scheduled() handler once per matching cron expression, and the app routes on controller.cron. Gotcha: Cloudflare cron weekdays are 1=Sun..7=Sat (not the Unix 0=Sun..6=Sat), so a bare 1 means 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 with wrangler and pasted in.
  • Secrets. API keys and tokens are set with wrangler secret put and are never committed. Local wrangler dev reads a gitignored .dev.vars instead.
  • Config and safety. Non-secret runtime flags live in wrangler.toml under [vars]. By convention an app ships with its side effects (posting, writing, sending) gated off — for example a DRY_RUN="1" flag — so a fresh deploy never acts until someone explicitly enables it.

Apps

linear-reports

The first Horns app: a scheduled Linear → Slack reporting framework. One cached Linear read per run feeds four cron-driven workflows that post to a Slack channel (#project-management):

  • weekly-load — the weekly per-person points report: planned + done = total with issue counts, each person’s biggest item linked, in-progress flags (overloaded / idle), 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.
  • daily-idle — flags roster members with nothing actively in progress. Individual people can be exempted with idle.excludeUserIds in src/core/config.ts; the exclusion is applied inside the idle heuristic, so it covers this alert and the weekly report’s idle flag, and nothing else — an exempted person still appears in the points table and is still evaluated by the approval and scope-creep reports. Runs daily at 07:00 UTC.
  • approval-reminder — lists issues waiting on an approver, either because the last comment @-mentions them and they haven’t replied, or because the issue has sat in their gate state past a stale window. The stale rule considers root issues only: approval is granted on the parent, so sub-issues sitting in the gate state don’t each raise their own reminder. An explicit @-mention still works on any issue, root or sub — that’s how you ask an approver to look at one specific child. Runs daily at 07:00 UTC.
  • daily-scope-creep — flags in-progress issues that gained scope after execution started — a subissue created after the parent started, or a leaf issue’s estimate raised after start — so mid-flight additions go back through planning instead of being absorbed ad hoc. A grace window absorbs additions made while finishing the plan in the same session. Runs daily at 07:00 UTC. Estimate-change history is added as a fourth leg of the single cached Linear read.

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

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

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):

  1. Authenticatewrangler login (or export CLOUDFLARE_API_TOKEN). One-time per machine, run by the deployer.
  2. Provision state — create any KV/D1/R2 namespaces (wrangler kv namespace create <BINDING>, plus --preview) and paste the returned ids into wrangler.toml.
  3. Set secretswrangler secret put <NAME> for each key or token. Never commit them.
  4. Validate dry-run first — exercise the app with side effects gated off (DRY_RUN="1") and read the output before enabling anything.
  5. Deploywrangler deploy (or the app’s pnpm deploy). A scheduled app registers its cron triggers on deploy; the schedule stays dormant until then.
  6. Go live deliberately — flip the gate (DRY_RUN="0") and redeploy, enabling one trigger at a time while watching wrangler tail.
  7. Rollback — re-gate (DRY_RUN="1") or drop the crons and redeploy, or wrangler rollback to 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

  • 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 in wrangler.toml.