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.
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.
| Prompt | What Tevyr AI generates |
|---|---|
| on session start, post to Slack and flash the timer red at 1 minute remaining | Lifecycle 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 rundown | Schedule 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 bell | Lifecycle 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 prompt | What changes |
|---|---|
| move the timer flash to staff only | Step 3's targetScreens param flips from ['all'] to ['staff']. No other step touched. |
| add a 5-second wait after the Slack post | A new flow.wait_ms step (ms: 5000) inserted between step 2 and step 3. Connectors redraw; numbering shifts. |
| swap the Slack channel to #producer-cues | Step 2's channel param updates. Everything else preserved. |
| change the Slack message to include the session name | Step 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:
| Resource | What it knows |
|---|---|
| Sessions | Names, durations, lifecycle status, start modes (target_time vs duration), schedule |
| Ad-hoc timers | All on-spot timers in the event, by name and current state |
| Polls | Open polls, quiz mode, linked chains, and current results |
| Integrations | Every connected HTTP integration in your account — Slack workspaces, Discord channels, Notion databases, OpenAI/Anthropic keys, Resend, Hue/WLED endpoints, custom HTTP, vMix, Zapier |
| Existing macros | When 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.
| Limit | Value |
|---|---|
| Generated macro length | Up to 25 steps per generation, expandable by refining |
| Macro total runtime | 12 hours (server-enforced on every fire — manual or AI-generated) |
| flow.wait_ms / wait_until | 6 hours max per wait |
| AI credits | Per-plan monthly cap (free, basic, premium tiers — visible on the dashboard pill) |
| Daily safety cap | Global 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:
| Event | When it fires |
|---|---|
| on_session_start | A session becomes the active session (manual start, scheduled, or auto-linked) |
| on_session_end | A session is marked completed (timer hit zero, manual skip, or auto-link advance) |
| on_session_pause | The active timer is paused (does NOT fire on timer.reset) |
| on_session_resume | A paused timer is resumed |
| on_session_warning | The remaining time crosses the warning threshold. Match a specific threshold (e.g. 120s) or accept any. |
| on_session_overtime | Timer 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, andflow.call_macro - Inspector (30%) — three tabs:
| Tab | Purpose |
|---|---|
| Step | Edit the selected step's parameters via verb-specific editor. Set per-step timeout + retry config here. |
| Playback | Real-time replay of the currently-running execution (or most recent if idle). Status glyphs, marching-ants connectors, heartbeat ticks. |
| History | Last 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
| Verb | Description | Required params |
|---|---|---|
| timer.start | Start the current session timer. | — |
| timer.pause | Pause the running timer; remaining time preserved. | — |
| timer.reset | Reset to full session duration. Optional duration_seconds override. | — |
| timer.add_time | Add seconds to the running timer. | seconds |
| timer.subtract_time | Subtract seconds from the running timer. | seconds |
| timer.scrub | Jump the timer to an exact remaining value. | remaining |
session
| Verb | Description | Required params |
|---|---|---|
| session.start | Make a named session current and load its timer (paused). Add a Start timer step after if you want the timer running. | sessionId |
| session.next | Advance to the next session in the rundown (paused). | — |
| session.prev | Step back to the previous session in the rundown (paused). | — |
| session.reset | Flip a session back to pending. Does not touch the active session or the timer. Defaults to '__current__'. | sessionId |
| session.skip | Mark a non-current session as completed. Cannot target the currently-active session. | sessionId |
adhoc
| Verb | Description | Required params |
|---|---|---|
| adhoc.start | Start a stand-alone timer (break, transition). Optional displayMode + targetScreens. | adhocId |
| adhoc.stop | Pause a running ad-hoc at its current remaining value. | adhocId |
| adhoc.reset | Reset to full duration and flip back to pending. | adhocId |
tp (teleprompter)
| Verb | Description | Required params |
|---|---|---|
| tp.play | Start scrolling from the current position. Mirrors the manual Play button. | teleprompterId |
| tp.pause | Pause scrolling at the current line. Panel stays visible. | teleprompterId |
| tp.stop | Stop scrolling, hide the panel, rewind to the top. | teleprompterId |
| tp.position | Jump 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
| Verb | Description | Required params |
|---|---|---|
| message.show | Display a message banner on chosen screens. | content |
| message.clear | Clear any displayed message. | — |
| message.flash | Briefly flash an attention-grabbing message. | content |
display
| Verb | Description | Required params |
|---|---|---|
| display.blackout | Black out chosen screens. | — |
| display.focus | Hide non-essential UI on chosen screens. | — |
| display.on_air | Show or hide the on-air indicator. Soft-skips if the plan blocks it. | — |
| display.flash_timer | Briefly flash the timer to grab attention. | — |
| display.disco_flash | Multi-color attention flash. Cancels red/green/panic when turned on. | — |
| display.red_light | Force a red signal across all screens. | — |
| display.green_light | Force a green signal across all screens. | — |
| display.panic | Pulsing red panic blackout. Cancels other emergency effects. | — |
| display.hide_session | Hide 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
| Verb | Description | Required params |
|---|---|---|
| appearance.set_timer_mode | Switch between countdown, count-up, time-of-day, or combined modes. | mode |
| appearance.set_overtime | Set overtime behavior (continue / stop / hide at 00) and/or prefix. | — |
| appearance.set_format | Set the countdown and/or time-of-day display format. | — |
sponsor
| Verb | Description | Required params |
|---|---|---|
| sponsor.show_wall | Display the sponsor wall overlay on every screen it targets. | — |
| sponsor.hide_wall | Hide the sponsor wall on every screen. | — |
polling
| Verb | Description | Required params |
|---|---|---|
| polling.open | Open any poll (incl. quizzes) for voting. Auto un-marks a done poll before activating. | pollId |
| polling.pause | Pause voting; clock preserved so subsequent open resumes. | pollId |
| polling.close | Close voting on a poll. | pollId |
| polling.mark_done | Close + hide + auto-advance the linked chain if any. | pollId |
| polling.reset_timer | Rewind the poll clock to full duration. Votes preserved. | pollId |
| polling.reset | Clear votes and reset to draft. Full wipe. | pollId |
| polling.display_poll | Show a poll on selected screens, or hide whichever poll is displayed. | show |
| polling.toggle_results | Show or hide vote counts on the audience screen. | pollId, show |
| polling.next_question | Advance to the next question in a linked poll/quiz chain. | — |
| polling.toggle_qr | Show or hide the audience-join QR code on selected screens. | show |
flow (control flow + HTTP)
| Verb | Description | Required params |
|---|---|---|
| flow.wait_ms | Pause the macro for N milliseconds. Cancel-aware via the Stop button. | ms |
| flow.wait_until | Pause 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_event | Pause until a session lifecycle event fires. Optional timeout + session filter. | event |
| flow.branch_if | Conditional execution — runs the then-branch or else-branch based on a JSON rule. | — |
| flow.parallel | Run two or more branches concurrently. Macro continues when all complete. | — |
| flow.call_macro | Invoke a saved macro inline. Cycle-protected and depth-capped at 5 nesting levels. | macroId |
| flow.http_request | Call 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:
| Variable | What 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_error | Behavior |
|---|---|
| abort | Step failure halts the chain. Macro status becomes failed. |
| continue | Step failure is logged, chain continues to the next step. Final status is failed if any step failed. |
| retry | Step 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.anyso 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 code | Resolve action |
|---|---|
| TIMEOUT, STEP_TIMEOUT | Open the step inspector → bump the timeout |
| VALIDATION_FAILED, INVALID_PARAMS | Open the step inspector → edit parameters |
| VERB_NOT_REGISTERED | Change verb or remove step |
| MACRO_NOT_FOUND, MACRO_CYCLE_DETECTED, DEPTH_EXCEEDED | Edit the called macro reference |
| SESSION_NOT_FOUND, ADHOC_NOT_FOUND, TP_NOT_FOUND, POLL_NOT_FOUND | Open 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:
| State | Visual |
|---|---|
| Running | Emerald spinner, current verb label, step trail, red Stop button |
| Success | Green check, elapsed ms, auto-dismisses after 5s |
| Failed | Red X, step index, Retry / Open in builder / Dismiss — persists until acknowledged |
| Cancelled | Grey, 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:
| Flag | What it does |
|---|---|
| skip_if_running | Before firing, check the macro registry. If the same macro is already in flight, skip this fire silently. |
| skip_if_missed_minutes | If 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_missed | On 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_runningto 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. Returns202 Acceptedimmediately with anexecution_id. Defaultsskip_if_running=trueso 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_warninglifecycle (whichever lands first wins viaskip_if_running). Three threshold lifecycle macros + one cron safety net. - Sub-macros — two reusable building blocks called from the main flow:
BROADCAST CUE — STAGE AandSPONSOR 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_startlifecycle (every round of the BO5 = a "session" in Tevyr). Two threshold macros + one cron chat-moderation cycle. - Sub-macros —
COMMUNITY HYPE FAN-OUT(dual-Discord + Twitter via Zapier) andCASTER 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)
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).
- Triggers — 3 schedules on the same macro:
7:55am,9:55am,11:55amAmerica/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-macros —
SANCTUARY 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
| Layer | Conference (R1) | Esports (R2) | Church (R3) |
|---|---|---|---|
| Trigger | Schedule + lifecycle warning (whichever first) | Lifecycle on_session_start (every round) | 3 schedules, one macro |
| Source-of-truth integration | Notion run-of-show + sponsor obligations | Notion bracket + sponsor obligations | Notion sermon archive + visitor CRM |
| Real-time orchestration | Slack to 3 channels, vMix lower-third, WLED + Hue + DMX | vMix sting, WLED team-color wall, dual-Discord, Sonos studio | Hue sanctuary, vMix record+stream, DMX board, sound board |
| AI for content velocity | OpenAI generates pre-approved social copy | OpenAI hype lines + Anthropic chat moderation | OpenAI compresses sermon to 3 bullets |
| Post-event automation | Notion impression ledger updated atomically | Notion bracket state + chat sentiment | Resend visitor cascade + Zapier→YouTube + Slack leadership |
| Compounding safety net | Speaker-no-show branch triggers fallback macro | 10-min cron checks sponsor delivery health | fire_late_if_missed covers snow days |
| Per-screen targeting | Speaker / audience / staff get different stories | Caster IFB / broadcast overlay / production booth | Pastor confidence / sanctuary / production booth |
| Private speaker cues | In-ear monitor via Generic HTTP | Caster IFB Slack channel | ListenWiFi 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.