Documentation

Macros

Bundle a sequence of timer, message, display, polling, and integration actions into a single click. Run macros manually, on a session-lifecycle event, by hotkey, on a schedule, or from the public API.

New here? Start with the visual tour

The Macros marketing page walks through every trigger type, workflow shape, and integration template with animated examples — a faster first read than this reference if you're evaluating whether macros fit your stack.

What a macro is

A macro is an ordered list of steps. Each step is one verb (e.g. timer.start, message.show, flow.http_request) with its parameters. When the macro fires, the server walks the list top-to-bottom and executes each step in order.

Macros support branching (flow.branch_if), parallelism (flow.parallel), and nested macro calls (flow.call_macro), so the chain can fork or fan out instead of being strictly linear.

Each macro has a trigger (or multiple triggers) that fires it — manually, on a session lifecycle event, on a hotkey press, or on a schedule. The server enforces a 12-hour cap per macro run, and per-step caps of 60 seconds (default 30s) unless overridden.

AI generation

You don't have to build macros by hand. Tevyr AI generates them from a plain-English prompt and refines them surgically when you want to change something. The same AI powers macro generation, the AI Showrunner rundown, and inline refines — one credit pool, one mental model across surfaces.

Generate from a prompt

Open the dashboard, find the AI bar at the top of the Macros panel, and describe the macro in plain English. Tevyr AI returns a complete macro — trigger wired, verbs picked, integrations resolved, parameters filled — ready to save and run.

PromptWhat Tevyr AI generates
on session start, post to Slack and flash the timer red at 1 minute remainingLifecycle trigger on_session_start + Slack message step + lifecycle threshold for on_session_warning(60s) firing display.flash with timer_color=red
every Sunday at 9:55am, send the producer cue and start the worship rundownSchedule trigger (cron 55 9 * * 0 in event timezone) + Slack send + session.start on the worship session
when round 5 ends, post the final scores to #broadcast and play the win bellLifecycle on_session_end scoped to Round 5 + Slack with {{session.name}} interpolation + adhoc.start on the WIN BELL ad-hoc timer

The macro lands in the builder with each generated step ringed in violet dashed with a small AI pill — visual proof that you can review every step before saving. Click any step to inspect or edit before you commit.

Refine an existing macro

Open any macro in the builder. The same AI bar lets you describe a change, and Tevyr AI mutates only the targeted step — preserving your retry config, on-error policy, trigger bindings, and the rest of the chain untouched.

Refine promptWhat changes
move the timer flash to staff onlyStep 3's targetScreens param flips from ['all'] to ['staff']. No other step touched.
add a 5-second wait after the Slack postA new flow.wait_ms step (ms: 5000) inserted between step 2 and step 3. Connectors redraw; numbering shifts.
swap the Slack channel to #producer-cuesStep 2's channel param updates. Everything else preserved.
change the Slack message to include the session nameStep 2's text gets a {{session.name}} interpolation token. Fires with live data at runtime.

Touched steps re-ring in violet so you can see the diff at a glance. The ring clears on interact (click, save, or edit) so the canvas returns to normal once you've confirmed the change.

What Tevyr AI sees

Before generating, Tevyr AI loads your event's actual state — no hallucinated session IDs, no fake Slack channels. It sees:

ResourceWhat it knows
SessionsNames, durations, lifecycle status, start modes (target_time vs duration), schedule
Ad-hoc timersAll on-spot timers in the event, by name and current state
PollsOpen polls, quiz mode, linked chains, and current results
IntegrationsEvery connected HTTP integration in your account — Slack workspaces, Discord channels, Notion databases, OpenAI/Anthropic keys, Resend, Hue/WLED endpoints, custom HTTP, vMix, Zapier
Existing macrosWhen refining, it sees the full step tree it's editing — including wrappers, branches, and nested calls

This is why refines are surgical — Tevyr AI knows exactly which step you mean, and what params are valid for the verb.

Limits and quotas

AI generation, refines, and the AI Showrunner all draw from the same monthly credit pool on your plan. The dashboard pill shows what you have left across every surface, so there's no hunt for separate counters.

LimitValue
Generated macro lengthUp to 25 steps per generation, expandable by refining
Macro total runtime12 hours (server-enforced on every fire — manual or AI-generated)
flow.wait_ms / wait_until6 hours max per wait
AI creditsPer-plan monthly cap (free, basic, premium tiers — visible on the dashboard pill)
Daily safety capGlobal per-user daily ceiling — prevents accidental quota burn from a script in a loop

Generation is opt-in — if your plan doesn't include AI credits, the AI bar gracefully prompts an upgrade instead of failing silently mid-flight.

Tips for better prompts

  • Name the trigger explicitly ("on session start", "every Sunday at 9:55", "when the round ends") — Tevyr AI binds it as the trigger automatically instead of guessing.
  • Name the integrations ("post to Slack", "send to Discord #cues", "fire the Notion update") — the AI resolves to your saved connection by name.
  • Reference live data with natural phrasing — "include the session name" or "with the remaining time" gets translated to {{session.name}} or {{timer.remaining}} interpolation.
  • For refines, point at the step — "the Slack step", "step 3", "the timer flash" — Tevyr AI uses positional + semantic matching to find it.
  • Don't over-specify timing — say "after a short pause" not "after exactly 4837ms"; the AI picks a reasonable default and you can refine it.

Triggers

A macro can be fired in four ways. The same macro can have several triggers active at once (e.g. manual + hotkey + lifecycle).

Manual

Open the Macros panel and click Run on any macro card. Also fires from the public API (GET /v1/macros/run) or from a hotkey press.

Hotkey

Bind any keyboard combo from the macro's Triggers tab. Combos serialize as strings (e.g. Ctrl+Shift+A, Meta+K). Hotkeys are intentionally suppressed while you're typing in <input>, <textarea>, contenteditable, or <select> so they don't interrupt regular form entry. If two enabled macros bind the same combo, the most-recently-loaded one wins.

Lifecycle

Fire a macro automatically when a session transition happens. Six lifecycle events are available:

EventWhen it fires
on_session_startA session becomes the active session (manual start, scheduled, or auto-linked)
on_session_endA session is marked completed (timer hit zero, manual skip, or auto-link advance)
on_session_pauseThe active timer is paused (does NOT fire on timer.reset)
on_session_resumeA paused timer is resumed
on_session_warningThe remaining time crosses the warning threshold. Match a specific threshold (e.g. 120s) or accept any.
on_session_overtimeTimer reaches zero and is configured to continue counting up

You can scope a lifecycle binding to a specific session (only that session triggers it) or leave it open to any session.

A safety guard caps lifecycle fan-out to 5 fires per event per 100ms to prevent cascade loops (e.g. a macro that ends a session, which fires on_session_end, which starts the next session, which fires on_session_start, etc.).

Schedule

Run a macro on a recurring or one-shot schedule. Configure via friendly date/time picker (Advanced reveals the raw cron expression). Schedules carry an IANA timezone and respect DST transitions correctly. See the Schedules section below for the full semantics.

Builder UI

Open the Macros panel from the controller, then click + New macro or any existing card. The builder fills most of the controller window with three regions:

  • Top bar — macro name, unsaved-changes badge, Run (Ctrl+Enter), Save (Ctrl+S), and zoom/help/close
  • Toolbar — search (opens the action palette popover), undo/redo, plus icons for Triggers, Schedule, and Settings
  • Canvas (70%) — the step chain itself, rendered as a vertical trunk with branching tree drops for flow.branch_if, flow.parallel, and flow.call_macro
  • Inspector (30%) — three tabs:
TabPurpose
StepEdit the selected step's parameters via verb-specific editor. Set per-step timeout + retry config here.
PlaybackReal-time replay of the currently-running execution (or most recent if idle). Status glyphs, marching-ants connectors, heartbeat ticks.
HistoryLast 25 runs of this macro. Click any row for the full step trace. Retry from a failed step, or use the Resolve dropdown for code-specific fixes.

Drag and drop

The builder supports two drag interactions:

  • Add a step — drag any verb from the Action palette (left rail or search popover) onto the canvas. Drop on a connector to insert at that position, drop into a branch slot to insert inside that branch.
  • Move a step — drag an existing step card to a new position or branch. The connectors update in place. Wrapper steps (flow.parallel, flow.branch_if) move with their entire subtree intact.

Both drag types are scoped — you can't accidentally drop a verb into a slot it doesn't belong in.

Verb catalog

Every shipped verb the server can execute, grouped by namespace. Click Action palette → Search in the builder to filter live. Verbs marked as required parameters in the table must be set before the step can save.

timer

VerbDescriptionRequired params
timer.startStart the current session timer.
timer.pausePause the running timer; remaining time preserved.
timer.resetReset to full session duration. Optional duration_seconds override.
timer.add_timeAdd seconds to the running timer.seconds
timer.subtract_timeSubtract seconds from the running timer.seconds
timer.scrubJump the timer to an exact remaining value.remaining

session

VerbDescriptionRequired params
session.startMake a named session current and load its timer (paused). Add a Start timer step after if you want the timer running.sessionId
session.nextAdvance to the next session in the rundown (paused).
session.prevStep back to the previous session in the rundown (paused).
session.resetFlip a session back to pending. Does not touch the active session or the timer. Defaults to '__current__'.sessionId
session.skipMark a non-current session as completed. Cannot target the currently-active session.sessionId

adhoc

VerbDescriptionRequired params
adhoc.startStart a stand-alone timer (break, transition). Optional displayMode + targetScreens.adhocId
adhoc.stopPause a running ad-hoc at its current remaining value.adhocId
adhoc.resetReset to full duration and flip back to pending.adhocId

tp (teleprompter)

VerbDescriptionRequired params
tp.playStart scrolling from the current position. Mirrors the manual Play button.teleprompterId
tp.pausePause scrolling at the current line. Panel stays visible.teleprompterId
tp.stopStop scrolling, hide the panel, rewind to the top.teleprompterId
tp.positionJump to a position (0–100%). Mirrors the manual scrub bar.teleprompterId, position

All four default teleprompterId to the sentinel __attached__, meaning "the teleprompter attached to the active session." Leave the default if you want the verb to follow whichever script the active session is using.

message

VerbDescriptionRequired params
message.showDisplay a message banner on chosen screens.content
message.clearClear any displayed message.
message.flashBriefly flash an attention-grabbing message.content

display

VerbDescriptionRequired params
display.blackoutBlack out chosen screens.
display.focusHide non-essential UI on chosen screens.
display.on_airShow or hide the on-air indicator. Soft-skips if the plan blocks it.
display.flash_timerBriefly flash the timer to grab attention.
display.disco_flashMulti-color attention flash. Cancels red/green/panic when turned on.
display.red_lightForce a red signal across all screens.
display.green_lightForce a green signal across all screens.
display.panicPulsing red panic blackout. Cancels other emergency effects.
display.hide_sessionHide the main session timer per screen (e.g. for ad-hoc to take the canvas).

Each per-screen verb defaults to all three screens (speaker, audience, staff) on. To turn an effect off, set enabled: false in the step params.

appearance

VerbDescriptionRequired params
appearance.set_timer_modeSwitch between countdown, count-up, time-of-day, or combined modes.mode
appearance.set_overtimeSet overtime behavior (continue / stop / hide at 00) and/or prefix.
appearance.set_formatSet the countdown and/or time-of-day display format.
VerbDescriptionRequired params
sponsor.show_wallDisplay the sponsor wall overlay on every screen it targets.
sponsor.hide_wallHide the sponsor wall on every screen.

polling

VerbDescriptionRequired params
polling.openOpen any poll (incl. quizzes) for voting. Auto un-marks a done poll before activating.pollId
polling.pausePause voting; clock preserved so subsequent open resumes.pollId
polling.closeClose voting on a poll.pollId
polling.mark_doneClose + hide + auto-advance the linked chain if any.pollId
polling.reset_timerRewind the poll clock to full duration. Votes preserved.pollId
polling.resetClear votes and reset to draft. Full wipe.pollId
polling.display_pollShow a poll on selected screens, or hide whichever poll is displayed.show
polling.toggle_resultsShow or hide vote counts on the audience screen.pollId, show
polling.next_questionAdvance to the next question in a linked poll/quiz chain.
polling.toggle_qrShow or hide the audience-join QR code on selected screens.show

flow (control flow + HTTP)

VerbDescriptionRequired params
flow.wait_msPause the macro for N milliseconds. Cancel-aware via the Stop button.ms
flow.wait_untilPause until a specific ISO timestamp or daily HH:MM in the event timezone. Past times soft-skip; HH:MM in the past today rolls to tomorrow.
flow.wait_for_eventPause until a session lifecycle event fires. Optional timeout + session filter.event
flow.branch_ifConditional execution — runs the then-branch or else-branch based on a JSON rule.
flow.parallelRun two or more branches concurrently. Macro continues when all complete.
flow.call_macroInvoke a saved macro inline. Cycle-protected and depth-capped at 5 nesting levels.macroId
flow.http_requestCall a saved HTTP integration (Slack, Notion, Discord, generic HTTP). See HTTP integrations below.connectionId, action

Flow control deep-dive

Branching: flow.branch_if

Two slots — Then and Else (else is optional). Each slot accepts any number of steps. Server evaluates the condition once at execution time and runs exactly one branch. The not-taken branch's step traces are tagged skipped in the playback timeline.

Parallel: flow.parallel

Two or more branch slots, all run concurrently. The macro waits for the slowest branch before moving to the next step. Each branch has its own step trace tree.

Nested macros: flow.call_macro

Pick another saved macro from a dropdown. The called macro runs inline as if its steps were inlined. Cycle detection prevents A→B→A loops; depth cap of 5 prevents runaway nested calls.

Waits: flow.wait_ms / wait_until / wait_for_event

  • flow.wait_ms — fixed delay in milliseconds. Up to 6 hours. Heartbeat broadcasts every 5s during long holds so the toast stays alive.
  • flow.wait_until — wait to an absolute time. Pick either an ISO datetime or daily HH:MM in the event's timezone. The picker shows the resolved fire-at in real time.
  • flow.wait_for_event — pause until a lifecycle event fires (on_session_start, on_session_end, etc.). Optional timeout. Soft-skips if the event already happened, so it's safe to use after a session transition without a race.

HTTP integrations: flow.http_request

Call any saved HTTP connection (Slack, Discord, Notion, generic webhook, etc.) as a macro step. Pick a connection from the dropdown, pick an action that the connection supports, then fill in the action's parameters.

All body, header, and URL fields support variable interpolation with {{ctx.*}} placeholders:

VariableWhat it resolves to
{{ctx.event.name}}Event type that triggered the macro (e.g. timer.started, on_session_start)
{{ctx.event.id}}Event ID (your room passcode)
{{ctx.event.title}}Event title from your room settings
{{ctx.event.timestamp}}ISO timestamp of the trigger event
{{ctx.session.title}}Title of the currently-active session
{{ctx.session.speaker_name}}Speaker on the active session, if set
{{ctx.session.duration_seconds}}Configured duration of the active session in seconds
{{ctx.timer.remaining_seconds}}Remaining seconds on the active timer
{{ctx.timer.duration_seconds}}Full duration of the active timer
{{ctx.timer.is_running}}Boolean — is the timer currently running
{{ctx.now.iso}}Current ISO timestamp
{{ctx.now.unix_ms}}Current Unix millis

For the full per-template setup (where to get the token, what scopes you need, what actions each template exposes), see the Integrations section and each template's individual doc page.

Retry, abort, continue, resolve

Per-step error policy

Each step has an on_error setting (defaults to abort):

on_errorBehavior
abortStep failure halts the chain. Macro status becomes failed.
continueStep failure is logged, chain continues to the next step. Final status is failed if any step failed.
retryStep re-runs up to retry.max times with retry.backoff_ms between attempts. Per-attempt traces are visible in playback + history.

retry.max caps at 10 attempts, retry.backoff_ms clamps to 100ms–10s per attempt.

Per-step + macro-level timeouts

  • Per-step timeout — defaults to 30s, configurable 100ms–60s. Applies to one execution of one step (each retry attempt restarts the timer).
  • Macro-level cap — hard 12-hour wall clock. Composes with per-step timeouts via AbortSignal.any so the first one to fire wins.

flow.wait_ms is special-cased: the per-step timeout is auto-raised to requested_ms + 5s so a 10-minute wait doesn't immediately time out.

Stop button

A red Stop button appears in the playback toast for any running execution. Clicking it propagates AbortError through the executor, including mid-flow.wait_ms (the wait is cancel-aware). The macro status becomes cancelled and the current step is marked cancelled in the trace.

Resolve dropdown

When a step fails, the playback timeline shows a Retry / Resolve split button on that row. The Resolve dropdown maps the error code to a contextual fix:

Error codeResolve action
TIMEOUT, STEP_TIMEOUTOpen the step inspector → bump the timeout
VALIDATION_FAILED, INVALID_PARAMSOpen the step inspector → edit parameters
VERB_NOT_REGISTEREDChange verb or remove step
MACRO_NOT_FOUND, MACRO_CYCLE_DETECTED, DEPTH_EXCEEDEDEdit the called macro reference
SESSION_NOT_FOUND, ADHOC_NOT_FOUND, TP_NOT_FOUND, POLL_NOT_FOUNDOpen the step inspector → fix the referenced ID
(unmapped)Bare Retry button

Retry from a step

In the History tab, any failed step has a ⟳ button. Clicking it re-runs the macro starting from that step — earlier steps are marked skipped in the new trace. Useful when the failure was transient (network blip, rate limit) and you don't want to re-do the side effects of the steps that already succeeded.

Inside a wrapper (branch slot, parallel branch, nested macro call), the retry uses a resumePath so only the matching slot re-runs — sibling slots are skipped.

Live execution

Execution toast

A small floating card appears at the bottom-right of every controller when any macro fires. Caps at 3 visible cards (older runs collapse under a +N more chip); position is draggable and persists per browser.

States:

StateVisual
RunningEmerald spinner, current verb label, step trail, red Stop button
SuccessGreen check, elapsed ms, auto-dismisses after 5s
FailedRed X, step index, Retry / Open in builder / Dismiss — persists until acknowledged
CancelledGrey, no Retry — clean user-initiated stop

If you don't want to see toasts from other connections (e.g. you're running a backup display), enable Quiet mode — the toast filters by actorConnectionId.

Live Playback tab

Open a macro in the builder while it's running and switch to the Playback tab. The entire step chain renders with live status glyphs per step, marching-ants connectors on the running edge, and heartbeat ticks during long flow.wait_ms holds.

History tab

Last 25 runs of this macro, newest first. Click any row to load its full step trace. Per-step expand reveals attempt-by-attempt detail (if retry > 1). The Retry/Resolve split button is available on failed steps the same way it is in Playback.

Schedules

Schedules let macros fire on their own — recurring (cron) or one-shot (specific datetime). All schedules carry an IANA timezone (default = your event's timezone) and respect DST transitions correctly.

Configuring

The schedule editor presents a friendly date/time picker by default — pick a start date, an end date (or unbounded), a recurrence (daily / weekly / monthly), and the time of day. Advanced reveals the raw cron expression for full control. A live "next 5 fires" preview shows exactly when the schedule will fire next so you can sanity-check.

Skip rules

Three independent flags control fire behavior:

FlagWhat it does
skip_if_runningBefore firing, check the macro registry. If the same macro is already in flight, skip this fire silently.
skip_if_missed_minutesIf the computed next-fire is more than N minutes in the past (default 5), skip this slot. Catches up to 'now' without firing for every missed slot during a server outage.
fire_late_if_missedOn server boot, fire one missed slot if any. When false (default), all missed slots during downtime are silently dropped with a log warning.

Conflict detection

The editor analyzes overlap before save and warns about three patterns:

  • Self-overlap — the schedule interval is shorter than the macro's estimated duration (so the next fire would land while the previous run is still going). Toggle skip_if_running to acknowledge if intentional.
  • Same-macro same-instant — two schedules for the same macro that fire within 60 seconds of each other.
  • Cross-macro same-instant — different macros firing within 60 seconds. Informational only.

Duration estimation is pessimistic (never under-counts). It correctly handles flow.wait_ms (literal), flow.parallel (max of branches), flow.branch_if (max of then/else), and flow.call_macro (recursion up to the 5-level cap).

Public /v1/ API

Two endpoints for hardware integrators. Both are GET-only and live under /v1/ so they work with Bitfocus Companion, vMix, OBS, and any tool that can hit a URL.

  • GET /v1/macros/list — list enabled macros for the room (id, name, description, icon, color, step count). Use this to populate a Stream Deck button bank.
  • GET /v1/macros/run — fire a macro by ID. Returns 202 Accepted immediately with an execution_id. Defaults skip_if_running=true so a bumped button doesn't fire concurrent runs.

See the API reference for full request/response shapes and curl examples.

Recipes

Tevyr is the conductor — not a replacement for vMix or Hue or Slack, but the playbook that keeps all of them on the same beat. The three recipes below are end-to-end production stacks pulled from real-world contexts. Each one shows a single macro firing across lights (Hue, WLED, DMX), sound (Sonos, in-ear monitors), cameras (vMix), comms (Slack, Discord, Resend), AI (OpenAI, Anthropic), and your CRM (Notion) — coordinated by schedule triggers, lifecycle thresholds, sub-macros, and per-screen targeting. They are not demos — they are the operating manual.

Every screen-targeted step (message.show, message.flash, display.flash_timer, polling.display_poll) accepts screens = [speaker, audience, staff] to route different content to the speaker's confidence monitor, the audience-facing big screen, and the production booth — three distinct stories on one timeline.

Recipe 1 — SaaS conference, Stage A 2pm changeover (full operator-relief stack)

The reality. A SaaStr-style conference, 3 stages running in parallel, sessions changing every 25 minutes from 9am–6pm. Sponsors paid $40K each for "logo on screen during 2pm slot" — that obligation is contractually tracked in Notion. Stage A's 2:00pm speaker is a flight risk (international, connecting through LAX, history of running 8 min late). Operator on Stage A is 22 years old, hired last week, has a clipboard with 19 numbered cues. One macro replaces the clipboard.

Setup

  • 3 screen types — Speaker (lectern confidence monitor), Audience (main hall LED wall), Staff (green-room + tech-booth monitors).
  • Integrations — Notion · Slack · Resend · OpenAI · Anthropic · vMix HTTP · WLED · Philips Hue · Generic HTTP (DMX house-light controller + in-ear monitor system).
  • Triggers — main macro fires on a 14:00 PT schedule AND on on_session_warning lifecycle (whichever lands first wins via skip_if_running). Three threshold lifecycle macros + one cron safety net.
  • Sub-macros — two reusable building blocks called from the main flow: BROADCAST CUE — STAGE A and SPONSOR DELIVERY LEDGER.

Sub-macro: BROADCAST CUE — STAGE A

One call, three lighting layers + the broadcast switcher, all synced. Used 4× per session changeover across all 3 stages.

1. flow.parallel
   ├─ flow.http_request   connection = "vMix HTTP — Main"
   │                      action = cut_to_input   input = "{{ctx.inputs.broadcast_input}}"
   ├─ flow.http_request   connection = "WLED — Stage A LED Wall"
   │                      action = set_preset   preset_id = {{ctx.inputs.wled_preset}}
   ├─ flow.http_request   connection = "Hue — Stage A Spots"
   │                      action = activate_scene   scene_id = "{{ctx.inputs.hue_scene}}"
   └─ flow.http_request   connection = "Generic HTTP — DMX House Wash"
                          method = POST
                          url = "https://dmx.lan.tunnel/v1/cue"
                          body = { cue: "{{ctx.inputs.dmx_cue}}", fade_ms: 1200 }

Sub-macro: SPONSOR DELIVERY LEDGER

Atomically logs every contractual sponsor impression to Notion and alerts sponsor success when an obligation falls short.

1. flow.http_request    connection = "Notion — Sponsor Obligations 2026"
                        action = update_row
                        row_id = "{{ctx.inputs.obligation_id}}"
                        properties = {
                          impressions: "+1",
                          last_fired: "{{ctx.now.iso}}",
                          stage: "{{ctx.inputs.stage}}",
                          session_title: "{{ctx.session.title}}"
                        }
2. flow.branch_if       condition: Notion response.impressions >= contractual_minimum
   ├─ THEN
   │   └─ flow.http_request    connection = "Slack — Tevyr"
   │                           channel = "#sponsor-success"
   │                           text = ":white_check_mark: `{{ctx.inputs.obligation_id}}` —
   │                                   minimum met ({{response.impressions}}/{{contractual_minimum}})"
   └─ ELSE
       └─ flow.http_request    connection = "Slack — Tevyr"
                               channel = "#sponsor-success"
                               text = ":warning: `{{ctx.inputs.obligation_id}}` —
                                       still owed {{remaining}} impressions"

Main macro: STAGE-A 2PM CHANGEOVER

1. flow.http_request    connection = "Notion — Conference Rundown 2026"
                        action = query_database
                        filter = { stage: "A", time: "2026-05-19T14:00-07:00" }
                        → ctx.next = rows[0]

2. flow.branch_if       condition: ctx.next.status == "confirmed-onsite"
   ├─ THEN
   │   └─ flow.call_macro    macroId = "SPEAKER GREENROOM PING"
   │                         inputs = { speaker: ctx.next, stage: "A",
   │                                    countdown_minutes: 15 }
   └─ ELSE  (speaker not confirmed — escalate, do not block stage)
       1. flow.http_request   connection = "Slack — Tevyr"
                              channel = "#exec-on-call"
                              text = ":rotating_light: NO-SHOW RISK Stage A 2pm"
       2. flow.call_macro     macroId = "STAGE-A FALLBACK SPONSOR DEMO"

3. flow.wait_until      time = "2026-05-19T13:55:00-07:00"   (5-min hard runway)

4. flow.parallel        (15 actions across 3 screens + 4 systems in one beat)
   ├─ session.next                                       (load 2pm session)
   ├─ tp.position           position = 0                 (rewind teleprompter)
   │
   ├─ message.show          screens = [audience]
   │                        content = "Up next: {{ctx.next.title}}
   │                                  with {{ctx.next.speaker_name}}"
   │                        duration = persist
   │
   ├─ message.show          screens = [staff]
   │                        content = "CHANGEOVER — {{ctx.next.speaker_name}} →
   │                                   sponsor `{{ctx.next.sponsor_obligation_id}}` →
   │                                   tease copy in #stage-a-ops"
   │
   ├─ message.flash         screens = [speaker]
   │                        content = "Welcome — green light when ready"
   │                        duration = 6s
   │
   ├─ display.green_light   on = true                    (visual "clear to enter")
   ├─ display.flash_timer                                 (timer pulse — speaker glances)
   │
   ├─ sponsor.show_wall                                  (logo on audience screen →
   │                                                      contractual impression begins)
   │
   ├─ adhoc.start           adhocId = "Walk-on countdown"  durationSeconds = 90
   │                        displayMode = full   targetScreens = [staff]
   │                        (90s ad-hoc visible ONLY to crew —
   │                         "you have 90s until speaker's mic is hot")
   │
   └─ flow.call_macro       macroId = "BROADCAST CUE — STAGE A"
                            inputs = { broadcast_input: "Stage A Lower-Third",
                                       wled_preset: 4, hue_scene: "Speaker Spotlight",
                                       dmx_cue: "C-127" }

5. flow.wait_ms         ms = 30000   (30s = contractual sponsor impression minimum)

6. flow.call_macro      macroId = "SPONSOR DELIVERY LEDGER"
                        inputs = { obligation_id: ctx.next.sponsor_obligation_id,
                                   stage: "A" }

7. flow.parallel        (hand the stage to the speaker)
   ├─ sponsor.hide_wall
   ├─ display.green_light   on = false
   ├─ message.clear         screens = [audience, speaker]
   ├─ adhoc.stop            adhocId = "Walk-on countdown"
   └─ timer.start                                        (25-min session timer begins)

8. tp.play

9. flow.parallel        (post-cue async — content automation, doesn't block the room)
   ├─ flow.http_request   connection = "OpenAI"   action = chat_completion
   │                      model = "gpt-4o-mini"
   │                      prompt = "Write 200-char tweet teasing this session
   │                                in 2nd person, no hashtags:
   │                                title='{{ctx.next.title}}',
   │                                speaker='{{ctx.next.speaker_name}}'"
   │                      → ctx.social
   ├─ flow.http_request   connection = "Slack — Tevyr"
   │                      channel = "#comms"
   │                      text = ":memo: Pre-approved social for 2pm:
   │                              ```{{ctx.social}}```"
   └─ flow.http_request   connection = "Notion — Conference Rundown 2026"
                          action = update_row
                          properties = { status: "live",
                                         started_at: "{{ctx.now.iso}}" }

Threshold macro: STAGE-A 5MIN-WARN — lifecycle on_session_warning (5 min)

Fires automatically the instant remaining time crosses below 300s. Different cue per screen — speaker gets the wrap signal, audience sees nothing alarming, staff gets ops info.

1. flow.parallel
   ├─ message.flash         screens = [speaker]
   │                        content = "5 MIN LEFT — start landing the plane"
   │                        duration = 8s
   ├─ display.flash_timer                                 (timer pulses on all screens —
   │                                                      audience reads as natural pacing,
   │                                                      speaker reads as a cue)
   ├─ message.show          screens = [staff]
   │                        content = "STAGE A — 5 MIN — prep next changeover"
   ├─ flow.http_request     connection = "Slack — Tevyr"
   │                        channel = "#stage-a-ops"
   │                        text = ":hourglass_flowing_sand: 5 min on Stage A
   │                                — next: {{ctx.event.next_session_title}}"
   └─ flow.http_request     connection = "Generic HTTP — In-Ear Monitor System"
                            method = POST
                            url = "https://shure-iem.lan.tunnel/v1/cue"
                            body = { stage: "A", channel: 3,
                                     audio_file: "5min_warning_bell.wav",
                                     volume: 30 }
                            (private audible bell in speaker's IEM —
                             no PA bleed, no audience hears it)

Threshold macro: STAGE-A OVERTIME — lifecycle on_session_overtime

Escalating cues. Sixty-second grace period after the first wrap signal — if the speaker takes the hint, clean shutdown. If not, the MC walks on.

1. flow.parallel        (round 1 — cues across all 3 screens)
   ├─ display.red_light       on = true                  (full audience-visible red signal)
   ├─ message.flash           screens = [speaker]
   │                          content = "PLEASE WRAP — we're past time"
   │                          duration = 12s
   ├─ message.show            screens = [staff]
   │                          content = "STAGE A IN OVERTIME — MC standing by"
   ├─ flow.http_request       connection = "Slack — Tevyr"
   │                          channel = "#exec-on-call"
   │                          text = ":no_entry: Stage A overtime —
   │                                  {{ctx.session.title}} blowing through 2:25pm"
   └─ flow.http_request       connection = "Generic HTTP — In-Ear Monitor System"
                              body = { stage: "A", channel: 3,
                                       audio_file: "wrap_up_now.wav", volume: 60 }

2. flow.wait_ms         ms = 60000   (60s grace — speakers often hear the cue and land it)

3. flow.branch_if       condition: ctx.session.is_running == true
   ├─ THEN  (still going — MC intervention)
   │   1. flow.http_request   connection = "Slack — Tevyr"
   │                          channel = "#mc-on-call"
   │                          text = ":microphone: MC — please walk on Stage A,
   │                                  apologize for overrun, thank the speaker"
   │   2. message.show        screens = [audience]
   │                          content = "Thanks {{ctx.session.speaker_name}} —
   │                                     5-min break before next session"
   │   3. adhoc.start         adhocId = "Emergency 5-min break"
   │                          durationSeconds = 300
   │                          displayMode = full
   │                          targetScreens = [audience, staff]
   │   4. flow.call_macro     macroId = "BROADCAST CUE — STAGE A"
   │                          inputs = { broadcast_input: "Break Slate",
   │                                     wled_preset: 1, hue_scene: "Break Bright",
   │                                     dmx_cue: "C-200" }
   └─ ELSE  (speaker landed — clean shutdown)
       └─ display.red_light   on = false

Threshold macro: STAGE-A TIMER-CRITICAL — webhook timer.critical (30s)

Hard-coded server event — fires once per crossing of the 30s threshold.

1. display.flash_timer                              (timer hard-pulses for 8s)
2. message.flash         screens = [speaker]
                         content = "30 SECONDS"   duration = 8s
3. flow.http_request     connection = "Generic HTTP — In-Ear Monitor System"
                         body = { audio_file: "30s_chime.wav", volume: 45 }

Cron safety net: SPONSOR DELIVERY HEALTH*/10 * * * * during showtime

1. flow.http_request    connection = "Notion — Sponsor Obligations 2026"
                        action = query_database
                        filter = { status: "active" }
                        → ctx.obligations

2. flow.http_request    connection = "Anthropic"   action = messages
                        model = "claude-haiku-4-5"
                        prompt = "Given these sponsor obligations and the remaining
                                  show time of {{ctx.show_time_remaining_minutes}}min,
                                  which are at risk of underdelivery? Return JSON
                                  {at_risk:[{id,gap_count,severity}]}.
                                  Data: {{ctx.obligations | json}}"
                        → ctx.risk_report

3. flow.branch_if       condition: ctx.risk_report.at_risk.length > 0
   ├─ THEN
   │   └─ flow.http_request   connection = "Slack — Tevyr"
   │                          channel = "#sponsor-success"
   │                          text = ":warning: {{ctx.risk_report.at_risk.length}}
   │                                  sponsors at risk —
   │                                  ```{{ctx.risk_report.at_risk | json}}```
   │                                  Re-shuffle remaining changeovers."
   └─ ELSE  (no-op — silence is healthy)

What this replaces. The operator's clipboard. The 15-min Slack ping no one remembered. The sponsor success team's spreadsheet someone updates from memory next week. The "did the speaker get the heads-up?" anxiety. The post-show "did we actually display the sponsor for the full 30s?" disagreement. The unfunny social copy that always lands at 4pm because the comms lead was busy.


Recipe 2 — Esports BO5 grand finals, bracket-driven broadcast + community + moderation

The reality. BO5 grand finals on Twitch. 800K concurrent viewers. Sponsor's contract says "logo on broadcast for first 60 seconds of each round AND on at least one match-decision moment per series." Two team Discord servers (60K + 45K members) need synchronized hype posts. Casters need a Notion-fed cheat sheet per round. Studio LED wall must match the leading team's brand color. Production crew is 4 people; without macros they'd need 9.

Setup

  • 3 screens — Speaker = caster IFB monitors (private cues to the casting pair). Audience = the actual Twitch/YouTube broadcast overlay (every viewer sees this). Staff = production booth + sponsor success room.
  • Integrations — Notion · Discord (×2 webhook URLs, one per team server) · Slack · OpenAI · Anthropic · vMix HTTP · WLED · Zapier · Generic HTTP (Sonos studio monitors + Twitch chat tap + Twitch mod bot).
  • Triggers — main macro fires on on_session_start lifecycle (every round of the BO5 = a "session" in Tevyr). Two threshold macros + one cron chat-moderation cycle.
  • Sub-macrosCOMMUNITY HYPE FAN-OUT (dual-Discord + Twitter via Zapier) and CASTER PROMPT (OpenAI hype line + private Slack IFB).

Sub-macro: COMMUNITY HYPE FAN-OUT

1. flow.parallel
   ├─ flow.http_request   connection = "Discord — Team Blue Server"
   │                      action = send_message
   │                      content = ":crossed_swords: **{{ctx.inputs.headline}}**
   │                                 — bracket: `{{ctx.inputs.score_state}}`
   │                                 → predict with 🔵/🔴"
   ├─ flow.http_request   connection = "Discord — Team Red Server"
   │                      action = send_message
   │                      content = (mirror copy, audience-appropriate framing)
   └─ flow.http_request   connection = "Zapier — Catch hook"
                          payload = { event: "round_hype",
                                      tweet_copy: "{{ctx.inputs.headline}} —
                                                   watch live twitch.tv/blast",
                                      schedule_at: "{{ctx.now.iso}}" }
                          (Zapier picks up → tweets from @BLASTPremier +
                           cross-posts to LinkedIn + queues TikTok teaser)

Sub-macro: CASTER PROMPT

1. flow.http_request    connection = "OpenAI"   action = chat_completion
                        prompt = "12-word esports hype line, no exclamation.
                                  Match: {{ctx.inputs.team_blue}} vs {{ctx.inputs.team_red}}.
                                  Series: {{ctx.inputs.score_state}}.
                                  Caster notes: {{ctx.inputs.caster_notes}}"
                        → ctx.hype
2. flow.http_request    connection = "Slack — Tevyr"
                        channel = "#caster-talkback"
                        text = ":microphone: TALKBACK: ```{{ctx.hype}}```"
3. message.show         screens = [speaker]
                        content = "Hype line in IFB"
                        duration = 6s
                        (visual confirmation on caster monitor — they don't have
                         to switch back to Slack to know it's ready)

Main macro: ROUND BELL — GRAND FINALS

1. flow.http_request    connection = "Notion — Bracket State"
                        action = query_database
                        filter = { round: "{{ctx.session.title}}" }
                        → ctx.round

2. flow.branch_if       condition: ctx.round.score_state contains "2 - 1" OR "1 - 2"
   ├─ THEN  (championship-point moment — escalated production cue)
   │   1. flow.parallel
   │      ├─ display.disco_flash    on = true            (audience-screen hype flash)
   │      ├─ display.flash_timer                          (timer hard-pulse)
   │      ├─ flow.http_request      connection = "vMix HTTP — Main"
   │      │                         action = cut_to_input
   │      │                         input = "Championship Point Sting"
   │      └─ flow.http_request      connection = "Generic HTTP — Sonos Studio"
   │                                method = POST
   │                                url = "https://sonos.local.tunnel/play"
   │                                body = { track: "championship_sting.wav",
   │                                         room: "Studio A", volume: 75 }
   │   2. flow.wait_ms              ms = 4000
   │   3. display.disco_flash       on = false
   │   4. flow.call_macro           macroId = "SPONSOR DELIVERY LEDGER"
   │                                inputs = { obligation_id:
   │                                           ctx.round.match_decision_sponsor,
   │                                           moment_type: "match_decision" }
   └─ ELSE  (standard round start)
       └─ display.green_light       on = true

3. flow.parallel        (the 60s sponsor + community + broadcast cue —
                         everything that must happen NOW, atomically)
   ├─ sponsor.show_wall                                  (sponsor logo on broadcast)
   │
   ├─ flow.http_request    connection = "WLED — Studio LED Wall"
   │                       action = set_color
   │                       hex = "{{ctx.round.dominant_team_hex}}"
   │
   ├─ message.show         screens = [staff]
   │                       content = "Round {{ctx.session.title}} —
   │                                  {{ctx.round.team_blue}} vs {{ctx.round.team_red}} —
   │                                  sponsor `{{ctx.round.sponsor_obligation_id}}` LIVE"
   │
   ├─ flow.call_macro      macroId = "COMMUNITY HYPE FAN-OUT"
   │                       inputs = { headline: "{{ctx.session.title}} live",
   │                                  score_state: ctx.round.score_state }
   │
   ├─ flow.call_macro      macroId = "CASTER PROMPT"
   │                       inputs = { team_blue: ctx.round.team_blue,
   │                                  team_red: ctx.round.team_red,
   │                                  score_state: ctx.round.score_state,
   │                                  caster_notes: ctx.round.caster_notes }
   │
   ├─ polling.open         pollId = "Round-by-round prediction"
   ├─ polling.display_poll show = true   targetScreens = [audience]
   └─ polling.toggle_qr    show = true   targetScreens = [audience]

4. flow.wait_ms         ms = 60000   (contractual sponsor impression window)

5. flow.parallel
   ├─ sponsor.hide_wall                                  (logo off — back to gameplay)
   ├─ polling.toggle_qr   show = false                   (QR off — votes are in)
   └─ flow.call_macro     macroId = "SPONSOR DELIVERY LEDGER"
                          inputs = { obligation_id: ctx.round.sponsor_obligation_id,
                                     stage: "broadcast" }

6. flow.wait_for_event  event = on_session_end   (round ends — elastic timing,
                                                  could be 4 min could be 47 min)

7. flow.parallel        (round-result cue + community post-mortem)
   ├─ polling.close              pollId = "Round-by-round prediction"
   ├─ polling.toggle_results     pollId = "Round-by-round prediction"
   │                             show = true
   ├─ message.show               screens = [staff]
   │                             content = "Round complete — intermission 60s →
   │                                        next round prep"
   │
   ├─ flow.http_request          connection = "vMix HTTP — Main"
   │                             action = cut_to_input   input = "Round Result LowerThird"
   │
   ├─ adhoc.start                adhocId = "Round intermission"
   │                             durationSeconds = 60
   │                             displayMode = full
   │                             targetScreens = [audience, staff]
   │                             (60s intermission countdown shown on broadcast +
   │                              production booth — casters know exactly when
   │                              they're back on)
   │
   ├─ flow.http_request          connection = "Anthropic"   action = messages
   │                             model = "claude-haiku-4-5"
   │                             prompt = "Summarize last 60s Twitch chat as 3 bullets
   │                                       + sentiment 1-10. Filter slurs/spam/raids.
   │                                       Chat: {{ctx.chat_dump}}"
   │                             → ctx.chat_summary
   ├─ flow.http_request          connection = "Slack — Tevyr"
   │                             channel = "#community-mods"
   │                             text = ":speech_balloon: Chat last 60s:
   │                                     ```{{ctx.chat_summary | json}}```"
   │
   └─ flow.http_request          connection = "Notion — Bracket State"
                                 action = update_row
                                 properties = { status: "completed",
                                                ended_at: "{{ctx.now.iso}}",
                                                sentiment_score:
                                                  "{{ctx.chat_summary.sentiment}}" }

8. flow.wait_ms         ms = 10000   (chart visibility on broadcast overlay)
9. polling.toggle_results pollId = "Round-by-round prediction"   show = false
10. polling.mark_done    pollId = "Round-by-round prediction"
                         (auto-advances to next round's poll in the chain)

11. flow.wait_ms         ms = 50000   (finish out the 60s intermission window —
                                       10s already burned on chart visibility)
12. adhoc.reset          adhocId = "Round intermission"   (ready for next round)

Threshold macro: ROUND LAST-30S — lifecycle on_session_warning (30s)

1. flow.parallel        (tense closing 30s — different cues per screen)
   ├─ display.red_light          on = true                (broadcast overlay tension)
   ├─ display.flash_timer                                  (timer hard-pulse)
   ├─ message.flash              screens = [speaker]
   │                             content = "LAST 30s — start outro patter"
   │                             duration = 8s
   ├─ message.flash              screens = [staff]
   │                             content = "30s — prep round-end cut"
   │                             duration = 8s
   ├─ flow.http_request          connection = "Generic HTTP — Sonos Studio"
   │                             body = { track: "tense_30s_bed.wav",
   │                                      room: "Studio A", fade_in_ms: 1500 }
   └─ flow.http_request          connection = "Notion — Bracket State"
                                 action = update_row
                                 properties = { last_30s_at: "{{ctx.now.iso}}" }

Threshold macro: ROUND OVERTIME — SUDDEN DEATH — lifecycle on_session_overtime

Rare in tactical esports — when it happens, it's a HUGE broadcast moment.

1. flow.parallel
   ├─ display.disco_flash        on = true               (3s broadcast hype flash)
   ├─ message.flash              screens = [audience]
   │                             content = "SUDDEN DEATH"   duration = 6s
   ├─ flow.http_request          connection = "vMix HTTP — Main"
   │                             action = cut_to_input
   │                             input = "Sudden Death Sting"
   ├─ flow.call_macro            macroId = "COMMUNITY HYPE FAN-OUT"
   │                             inputs = { headline: "⚡ SUDDEN DEATH ⚡
   │                                                    {{ctx.round.team_blue}} vs
   │                                                    {{ctx.round.team_red}}",
   │                                        score_state: ctx.round.score_state }
   └─ flow.http_request          connection = "Generic HTTP — Sonos Studio"
                                 body = { track: "sudden_death_riser.wav",
                                          room: "Studio A", volume: 85 }

2. flow.wait_ms         ms = 3000
3. display.disco_flash  on = false

Cron: CHAT MOD CYCLE*/2 * * * *

Every 2 minutes during broadcast, sample the chat firehose, run it through Claude Haiku for flag detection, and auto-timeout via the Twitch mod bot.

1. flow.http_request    connection = "Generic HTTP — Twitch Chat Tap"
                        method = GET   url = "https://chat-tap.local/last-2min"
                        → ctx.chat
2. flow.http_request    connection = "Anthropic"   action = messages
                        model = "claude-haiku-4-5"
                        prompt = "Flag any of these messages for moderation:
                                  spam, raids, slurs, doxxing, brigading.
                                  Return {flag:[{user,msg,reason}]}.
                                  Messages: {{ctx.chat | json}}"
                        → ctx.flags
3. flow.branch_if       condition: ctx.flags.flag.length > 0
   ├─ THEN
   │   1. flow.http_request   connection = "Slack — Tevyr"
   │                          channel = "#community-mods-action"
   │                          text = ":no_entry: {{ctx.flags.flag.length}} flagged —
   │                                  ```{{ctx.flags | json}}```"
   │   2. flow.http_request   connection = "Generic HTTP — Twitch Mod Bot"
   │                          method = POST   url = "https://modbot.local/timeout"
   │                          body = { users: "{{ctx.flags.flag[*].user}}",
   │                                   duration_s: 600 }
   └─ ELSE  (no-op)
Multi-screen wisdom

The audience-facing broadcast overlay sees the disco flash, the sponsor logo, the red light, the poll bar chart, the "SUDDEN DEATH" message. The caster IFB (speaker screen) sees the private hype-line confirmation, the "LAST 30s" cue, never the polls. The production booth (staff) sees the sponsor obligation ID, the moderator alerts, the intermission countdown. Three different stories, one timeline, one macro.

What this replaces. The director's whisper "play the sting" that always misses by half a second. The community manager toggling between 2 Discord servers trying to type the same hype post twice. The sponsor success team manually counting logo seconds from a recording, post-show, to prove contractual delivery. The casters reading flat scripted hype lines because no one had time to write fresh ones. The mod team drowning in chat. The "oh shit we owe Sponsor X two more impressions and we're 20 min from end-of-stream" panic.


Recipe 3 — Multi-service church Sunday + offering moment + post-service visitor cascade

The reality. Suburban megachurch. 3 Sunday services: 8am traditional, 10am family (kids ministry running parallel), 12pm bilingual Spanish/English. Each service is 75 min, must be byte-identical in production. Volunteer tech team rotates weekly — the operator running 8am may not be the operator running 10am. First-time visitors who scan the "I'm new here" QR (a sponsor-tier asset) must get a welcome email within 90 minutes with a Calendly link to meet a pastor that week, or the conversion rate cuts in half. Senior pastor wants Monday morning Slack with a 3-bullet AI summary of every sermon for staff meeting prep.

Setup

  • 3 screens — Speaker = pastor's confidence monitor at the lectern (timer + teleprompter + sermon outline + private cues). Audience = sanctuary screens (worship lyrics, sermon visuals, scripture, offering prompt). Staff = production booth + green-room monitors (run-of-show, lighting cues, livestream health).
  • Integrations — Notion · Slack · Resend · OpenAI · Philips Hue · vMix HTTP · Zapier · Generic HTTP (pastor's ListenWiFi earpiece + sanctuary DMX board + sound board).
  • Triggers3 schedules on the same macro: 7:55am, 9:55am, 11:55am America/Chicago, fire_late_if_missed = true (snow days, leadership delays), skip_if_running = true. Three threshold lifecycle macros (worship-to-message handoff, 5min-warn, overtime).
  • Sub-macrosSANCTUARY HUE, OFFERING MOMENT, VISITOR WELCOME CASCADE, SERMON ARCHIVE PIPELINE.

Sub-macro: SANCTUARY HUE

Two lighting controllers, one beat. Used for every scene transition.

1. flow.parallel
   ├─ flow.http_request   connection = "Hue — Sanctuary"
   │                      action = activate_scene
   │                      scene_id = "{{ctx.inputs.hue_scene}}"
   │                      transition_ms = {{ctx.inputs.fade_ms}}
   └─ flow.http_request   connection = "Generic HTTP — Sanctuary DMX Board"
                          method = POST   url = "https://dmx.church.lan/v1/cue"
                          body = { cue: "{{ctx.inputs.dmx_cue}}",
                                   fade_ms: "{{ctx.inputs.fade_ms}}" }

Sub-macro: OFFERING MOMENT

90-second choreographed beat across all 3 screens with private earpiece chime to the ushers' lead.

1. flow.parallel        (3-screen choreographed beat)
   ├─ display.focus              on = true                (kill non-essential audience UI)
   ├─ message.show               screens = [audience]
   │                             content = "Tithes & Offerings —
   │                                        text GIVE to 73256 OR scan the QR"
   │                             duration = persist
   ├─ message.show               screens = [speaker]
   │                             content = "Offering moment — 90s on the clock"
   │                             duration = persist
   ├─ message.show               screens = [staff]
   │                             content = "OFFERING — ushers walking"
   ├─ flow.call_macro            macroId = "SANCTUARY HUE"
   │                             inputs = { hue_scene: "Warm Reflective",
   │                                        dmx_cue: "C-180",   fade_ms: 4000 }
   ├─ adhoc.start                adhocId = "Offering countdown"
   │                             durationSeconds = 90
   │                             displayMode = compact
   │                             targetScreens = [audience, staff]
   └─ flow.http_request          connection = "Generic HTTP — Sound Board"
                                 method = POST   url = "https://soundboard.church.lan/play"
                                 body = { track: "offering_instrumental.wav",
                                          channel: 7, volume: 60, fade_in_ms: 2000 }

2. flow.wait_ms         ms = 75000   (75s = 90s countdown minus the last 15s wrap cue)
3. message.flash         screens = [staff]
                         content = "USHERS — 15s — please return to back"
                         duration = 10s

4. flow.wait_ms          ms = 15000   (last 15s of the offering countdown)

5. flow.parallel        (clean handoff back to sermon)
   ├─ display.focus              on = false
   ├─ message.clear              screens = [audience, speaker, staff]
   ├─ adhoc.reset                adhocId = "Offering countdown"
   ├─ flow.call_macro            macroId = "SANCTUARY HUE"
   │                             inputs = { hue_scene: "Sermon Focus",
   │                                        dmx_cue: "C-150",   fade_ms: 3000 }
   └─ flow.http_request          connection = "Generic HTTP — Sound Board"
                                 body = { track: "offering_instrumental.wav",
                                          channel: 7, action: "fade_out_ms",
                                          duration: 2000 }

Sub-macro: VISITOR WELCOME CASCADE

Called at the end of every service. Queries Notion for QR scans since service start, batches a Resend welcome email, alerts pastoral care, marks the rows as emailed.

1. flow.http_request    connection = "Notion — First-Time Visitors"
                        action = query_database
                        filter = { scan_timestamp: { after: ctx.session.started_at },
                                   shortcode: "im-new" }
                        → ctx.new_visitors

2. flow.branch_if       condition: ctx.new_visitors.length > 0
   ├─ THEN
   │   1. flow.http_request   connection = "Resend"   action = send_batch
   │                          to = "{{ctx.new_visitors[*].email}}"
   │                          subject = "We're glad you joined us this morning"
   │                          html = (welcome template w/ Calendly link to meet a pastor,
   │                                  small-group sign-up, parking-pass auto-issue)
   │   2. flow.http_request   connection = "Slack — Tevyr"
   │                          channel = "#pastoral-care"
   │                          text = ":raised_hands: {{ctx.new_visitors.length}}
   │                                  first-time visitors today —
   │                                  welcome emails sent, Calendly bookings
   │                                  populate Notion within 24h"
   │   3. flow.http_request   connection = "Notion — First-Time Visitors"
   │                          action = bulk_update
   │                          ids = "{{ctx.new_visitors[*].id}}"
   │                          properties = { welcome_email_sent: true,
   │                                         welcome_email_at: "{{ctx.now.iso}}" }
   └─ ELSE
       └─ flow.http_request   connection = "Slack — Tevyr"
                              channel = "#welcome-team"
                              text = ":information_source: No QR scans this service.
                                      Check QR placement before next service."

Sub-macro: SERMON ARCHIVE PIPELINE

Called after every service ends. Stops the broadcast rig, hands the recording to Zapier (which already has a 12-step YouTube workflow), generates a leadership summary via OpenAI, fans out via Slack and the visitor cascade.

1. flow.parallel        (post-service close)
   ├─ flow.http_request          connection = "vMix HTTP — Main"
   │                             action = stop_recording
   ├─ flow.http_request          connection = "vMix HTTP — Main"
   │                             action = stop_streaming
   ├─ flow.call_macro            macroId = "SANCTUARY HUE"
   │                             inputs = { hue_scene: "House Lights Full",
   │                                        dmx_cue: "C-400",   fade_ms: 6000 }
   ├─ sponsor.hide_wall
   └─ tp.stop

2. flow.wait_ms         ms = 30000     (let vMix finalize recording file)

3. flow.parallel        (archive + AI summary + visitor cascade — all post-service)
   ├─ flow.http_request    connection = "Zapier — Catch hook"
   │                       payload = { event: "sermon_recording_complete",
   │                                   file_path: "/recordings/{{ctx.now.iso}}.mp4",
   │                                   title: "{{ctx.sermon.sermon_title}}",
   │                                   service_type: "{{ctx.schedule.service_type}}" }
   │                       (Zapier picks up → uploads YouTube → adds chapters →
   │                        emails congregation @ 6pm)
   ├─ flow.call_macro      macroId = "VISITOR WELCOME CASCADE"
   └─ flow.http_request    connection = "OpenAI"   action = chat_completion
                           model = "gpt-4o"
                           prompt = "3 bullets, max 20 words each, for Monday staff
                                     meeting prep: {{ctx.sermon.outline_text}}"
                           → ctx.summary

4. flow.http_request    connection = "Slack — Tevyr"
                        channel = "#pastoral-leadership"
                        text = ":scroll: *{{ctx.sermon.sermon_title}}*
                                ({{ctx.schedule.service_type}},
                                 {{ctx.now.iso | datetime}}):
                                ```{{ctx.summary}}```
                                Recording → YouTube via Zapier by 6pm.
                                Visitors emailed: {{ctx.new_visitors.length}}."

Main macro: SUNDAY SERVICE START

One macro, three schedules. Identical execution at 8am, 10am, and 12pm.

1. flow.http_request    connection = "Notion — Sermon Archive 2026"
                        action = query_database
                        filter = { date: "{{ctx.now.iso | date}}",
                                   service_type: "{{ctx.schedule.service_type}}" }
                        → ctx.sermon

2. flow.parallel        (5-min runway — every system aligned to a single beat)
   ├─ flow.call_macro            macroId = "SANCTUARY HUE"
   │                             inputs = { hue_scene: "Worship Warm Dim",
   │                                        dmx_cue: "C-100",   fade_ms: 6000 }
   │
   ├─ flow.http_request          connection = "vMix HTTP — Main"
   │                             action = start_recording
   ├─ flow.http_request          connection = "vMix HTTP — Main"
   │                             action = start_streaming
   │
   ├─ tp.position                position = 0
   ├─ sponsor.show_wall                                  (rotating ministry-partner slides)
   │
   ├─ message.show               screens = [audience]
   │                             content = "Welcome — service begins shortly"
   ├─ message.show               screens = [speaker]
   │                             content = "Mic check at 5min mark"
   ├─ message.show               screens = [staff]
   │                             content = "T-5 — service `{{ctx.schedule.service_type}}`
   │                                        — sermon `{{ctx.sermon.sermon_title}}`"
   │
   ├─ adhoc.start                adhocId = "Mic check countdown"
   │                             durationSeconds = 300
   │                             displayMode = compact
   │                             targetScreens = [speaker, staff]
   │
   ├─ flow.http_request          connection = "Slack — Tevyr"
   │                             channel = "#stage-team"
   │                             text = ":church: T-5 to service. Type:
   │                                     `{{ctx.schedule.service_type}}`.
   │                                     Sermon: *{{ctx.sermon.sermon_title}}*"
   │
   └─ flow.http_request          connection = "Resend"   action = send_email
                                 to = "smallgroupleaders@church.org"
                                 subject = "This week's small-group discussion guide"
                                 html = (templated from ctx.sermon.outline_text)

3. flow.branch_if       condition: ctx.schedule.service_type == "bilingual"
   ├─ THEN
   │   └─ message.show      screens = [audience]
   │                        content = "Traducción simultánea: 88.5 FM"
   │                        duration = persist
   │                        (the Spanish-overflow teleprompter is pre-loaded
   │                         on its own controller before service — this branch
   │                         only flips the FM-station notice on the main screen)
   └─ ELSE  (no-op)

4. flow.wait_until      time = "08:00:00 America/Chicago"   (or 10:00 or 12:00 —
                                                              schedule-aware)

5. flow.parallel        (service starts — speaker hot, audience focused, staff briefed)
   ├─ message.clear              screens = [audience, speaker]
   ├─ adhoc.stop                 adhocId = "Mic check countdown"
   ├─ sponsor.hide_wall
   ├─ session.start                                      (load "Worship Set" session)
   ├─ timer.start
   └─ flow.call_macro            macroId = "SANCTUARY HUE"
                                 inputs = { hue_scene: "Worship Full",
                                            dmx_cue: "C-110",   fade_ms: 3000 }

# ~25 min of worship plays out → on_session_end fires →
#   WORSHIP-TO-MESSAGE threshold macro takes over.
# Pastor preaches ~40 min, fires OFFERING MOMENT via clicker hotkey mid-sermon.
# on_session_warning (5min) + on_session_end fire their threshold macros.

Threshold macro: WORSHIP-TO-MESSAGE — lifecycle on_session_end (filtered to worship-set)

Fast lighting shift + clear lyrics on audience + brief pastor on speaker + alert booth on staff + private chime in pastor's earpiece.

1. flow.parallel
   ├─ tp.stop                    teleprompterId = "Worship lyrics"
   │                             (lyrics teleprompter clears from audience screen)
   ├─ message.flash              screens = [speaker]
   │                             content = "You're up — scripture is
   │                                        {{ctx.sermon.scripture_refs}}"
   │                             duration = 10s
   ├─ message.show               screens = [staff]
   │                             content = "Worship done → message starts"
   ├─ flow.call_macro            macroId = "SANCTUARY HUE"
   │                             inputs = { hue_scene: "Sermon Focus",
   │                                        dmx_cue: "C-150",   fade_ms: 5000 }
   │                             (slow 5s fade — pastor walks up during it)
   ├─ flow.http_request          connection = "Generic HTTP — Pastor's ListenWiFi"
   │                             method = POST
   │                             url = "https://listenwifi.church.lan/cue"
   │                             body = { channel: 12,
   │                                      audio_file: "youre_up_chime.wav",
   │                                      volume: 50 }
   │                             (private audible chime in pastor's earpiece —
   │                              no one else hears it)
   └─ session.next                                       (load "Sermon" session)

2. tp.play              teleprompterId = "Sermon outline"
3. timer.start

Threshold macro: SERVICE 5MIN-WARN — lifecycle on_session_warning (5 min)

1. flow.parallel        (warn pastor on speaker screen, prep welcome team on staff,
                         do NOT alarm the audience)
   ├─ message.flash              screens = [speaker]
   │                             content = "5 MIN — start landing"
   │                             duration = 8s
   ├─ display.flash_timer                                 (subtle timer pulse —
   │                                                      audience reads as pacing)
   ├─ message.show               screens = [staff]
   │                             content = "5 MIN to dismiss — welcome team to lobby"
   ├─ flow.http_request          connection = "Slack — Tevyr"
   │                             channel = "#welcome-team"
   │                             text = ":wave: 5 min to dismiss — welcome team
   │                                     to lobby. Today's first-time visitor
   │                                     count: check /sponsor/im-new/dashboard"
   └─ flow.http_request          connection = "Generic HTTP — Pastor's ListenWiFi"
                                 body = { channel: 12,
                                          audio_file: "5min_chime.wav", volume: 30 }

Threshold macro: SERVICE OVERTIME — lifecycle on_session_overtime

Pastors run long. The cue must be polite, escalating, and never embarrass the pastor publicly.

1. flow.parallel        (round 1 — gentle private cue, NO audience visibility)
   ├─ message.flash              screens = [speaker]
   │                             content = "Past time — please land"
   │                             duration = 10s
   ├─ display.flash_timer                                 (timer pulses on speaker only)
   └─ flow.http_request          connection = "Generic HTTP — Pastor's ListenWiFi"
                                 body = { channel: 12,
                                          audio_file: "wrap_up_gentle.wav", volume: 45 }

2. flow.wait_ms         ms = 60000

3. flow.branch_if       condition: ctx.session.is_running == true
   ├─ THEN  (still going — escalate to worship leader, prep music bed)
   │   1. message.show           screens = [staff]
   │                             content = "OVERTIME — worship leader prep music bed"
   │   2. flow.http_request      connection = "Slack — Tevyr"
   │                             channel = "#worship-team"
   │                             text = ":musical_note: Service in overtime —
   │                                     music team prep for closing on cue"
   │   3. flow.http_request      connection = "Generic HTTP — Pastor's ListenWiFi"
   │                             body = { audio_file: "wrap_up_firm.wav",
   │                                      volume: 60 }
   │   4. flow.wait_ms           ms = 60000
   │   5. flow.branch_if         condition: ctx.session.is_running == true
   │      ├─ THEN  (still going after 2 min — MC fade-out + close cue)
   │      │   1. flow.http_request   connection = "Generic HTTP — Sound Board"
   │      │                          body = { track: "closing_bed.wav",
   │      │                                   channel: 3, fade_in_ms: 8000 }
   │      │   2. flow.call_macro     macroId = "SANCTUARY HUE"
   │      │                          inputs = { hue_scene: "Closing Bright",
   │      │                                     dmx_cue: "C-300", fade_ms: 8000 }
   │      └─ ELSE  (pastor landed — clean shutdown)
   └─ ELSE  (took the hint — silence)

What this replaces. The volunteer tech team's "did I remember to start recording?" anxiety (it now starts itself, 3 times). The small-group leader email someone sends manually on Friday (now Sunday morning, automated, from the actual sermon outline). The pastoral care team's Tuesday morning manual export of QR scans (now Sunday afternoon, automated, with welcome emails already delivered while the visitor is still in their car). The 4-day delay between Sunday's sermon and Monday's staff meeting summary (now Sunday afternoon, 3 bullets, in Slack). The 6-hour gap between service end and the YouTube upload (now automated via Zapier within 90 min). The "we missed 4 first-time visitors last month because the welcome email went out Friday" guilt.


The pattern across all three

LayerConference (R1)Esports (R2)Church (R3)
TriggerSchedule + lifecycle warning (whichever first)Lifecycle on_session_start (every round)3 schedules, one macro
Source-of-truth integrationNotion run-of-show + sponsor obligationsNotion bracket + sponsor obligationsNotion sermon archive + visitor CRM
Real-time orchestrationSlack to 3 channels, vMix lower-third, WLED + Hue + DMXvMix sting, WLED team-color wall, dual-Discord, Sonos studioHue sanctuary, vMix record+stream, DMX board, sound board
AI for content velocityOpenAI generates pre-approved social copyOpenAI hype lines + Anthropic chat moderationOpenAI compresses sermon to 3 bullets
Post-event automationNotion impression ledger updated atomicallyNotion bracket state + chat sentimentResend visitor cascade + Zapier→YouTube + Slack leadership
Compounding safety netSpeaker-no-show branch triggers fallback macro10-min cron checks sponsor delivery healthfire_late_if_missed covers snow days
Per-screen targetingSpeaker / audience / staff get different storiesCaster IFB / broadcast overlay / production boothPastor confidence / sanctuary / production booth
Private speaker cuesIn-ear monitor via Generic HTTPCaster IFB Slack channelListenWiFi earpiece via Generic HTTP

The macros aren't doing one thing — they're the conductor that makes 8 systems sing on the same beat. Three times a Sunday. Every round of a BO5. Every changeover at a 1,500-person conference. The integrations are the orchestra; the macro is what keeps them in time. The schedule + lifecycle triggers are what let the conductor be on a beach in Portugal while the show runs itself.