<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Scheduling on Damian Galarza | Production AI Consulting</title><link>https://www.damiangalarza.com/tags/scheduling/</link><description>Recent posts from Damian Galarza | Production AI Consulting</description><generator>Hugo</generator><language>en-us</language><managingEditor>Damian Galarza</managingEditor><atom:link href="https://www.damiangalarza.com/tags/scheduling/feed.xml" rel="self" type="application/rss+xml"/><item><title>Scheduling Mastra Agents: A Weekly Report That Delivers Itself to Slack</title><link>https://www.damiangalarza.com/posts/2026-08-25-scheduling-mastra-agents/</link><pubDate>Tue, 25 Aug 2026 00:00:00 -0400</pubDate><author>Damian Galarza</author><guid>https://www.damiangalarza.com/posts/2026-08-25-scheduling-mastra-agents/</guid><description>Schedule a Mastra agent with a scheduled workflow: declarative cron, date ranges in code, an evidence check, and a Slack post the model can't trigger.</description><content:encoded><![CDATA[<p>An agent that answers questions on demand is only as useful as your habit of asking it. The interesting engineering starts when you want the agent to run on its own, because now nobody is there to catch a bad date range or an unsupported claim before it goes out. This post walks through how I moved one of my agents onto a schedule with Mastra, and the decisions that came with it.</p>
<p>One of my favorite agents I&rsquo;ve built with <a href="https://www.mastra.ai">Mastra</a> is called Beacon. It tracks analytics across my content: Google Analytics, PostHog, and YouTube Studio. I find myself enjoying Slack as the interface for my agents. It just feels natural. When this exchange happened, I was still going through Emma, the supervisor agent that routed analytics questions to Beacon. Beacon has its own Slack app now. A typical exchange looks like this:</p>
<blockquote>
<p>@Emma tell me how our traffic looked week over week from the past month</p></blockquote>
<p><img src="/images/posts/beacon-slack-weekly-traffic.png" alt="Slack thread where Emma delegates the week-over-week traffic question to Beacon. The reply lists four weekly periods, a metrics table of sessions, users, pageviews, bounce rate, and session length, and notes that /services/ jumped from 45 to 302 pageviews"></p>
<p>What I find most interesting here isn&rsquo;t that Beacon pulled four 7-day periods from GA4 and laid out the numbers. It&rsquo;s that it noticed an anomaly and brought it to my attention:</p>
<blockquote>
<p>/services/ went from 45 pageviews to 302, becoming the #1 page. Likely tied to the paid campaign that appeared this week.</p></blockquote>
<p>I confirmed with Beacon that this was in fact the case. I had been trying out a small sampling of Google Ads. From there, Beacon dug in further: 174 sessions from the campaign, five CTA clicks, zero downstream conversions, and a list of what to check next. Beacon also has access to the website&rsquo;s codebase, so it can connect what the analytics say to what the pages actually do.</p>
<p>This is great, but I still had to go into Slack and ask. Not bad, but we can do better. I&rsquo;d rather my agent be doing this on its own than relying on me to initiate. I wanted the weekly version of this report to show up on Monday morning without me in the loop.</p>
<h2 id="two-ways-to-schedule-work-in-mastra">Two Ways to Schedule Work in Mastra</h2>
<p>Mastra has two primitives for this, and the choice between them shaped everything else in the implementation.</p>
<p><a href="https://mastra.ai/docs/harness/schedules">Schedules</a> let you attach a cron to an agent. On each fire, the agent receives a prompt and runs. For &ldquo;ask Beacon this question every Monday,&rdquo; that is the simplest possible setup.</p>
<p><a href="https://mastra.ai/docs/workflows/scheduled-workflows">Scheduled workflows</a> let you attach a cron to a workflow instead. On each fire, the workflow runs from its first step with a fixed <code>inputData</code>.</p>
<p>I went with the workflow, and the reason is delivery. The report has to be posted to Slack, and posting is a mutation. Beacon is otherwise read-only by design: every analytics tool it has returns data and nothing else. An agent schedule would have needed a Slack-posting tool exposed to the model so the agent could deliver its own report. That turns the side effect into something the model decides to do, and it leaves a mutation capability sitting in Beacon&rsquo;s toolset during ordinary conversations.</p>
<figure class="post-diagram" style="margin:2rem 0 2.5rem;">
<svg viewBox="0 0 400 300" role="img" aria-label="Two ways to schedule the report. An agent schedule puts a Slack-posting tool inside the model's toolset and the model decides to post. A scheduled workflow gives the model one read-only tool in step one and posts from code in step two." style="max-width:400px;width:100%;height:auto;display:block;margin:0 auto;font-family:inherit;color:#c9c5bc;">
<defs>
<marker id="sched-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker>
</defs>
<text x="8" y="14" font-size="11" font-weight="600" letter-spacing="1" fill="currentColor">AGENT SCHEDULE</text>
<text x="216" y="14" font-size="11" font-weight="600" letter-spacing="1" fill="currentColor">SCHEDULED WORKFLOW</text>
<rect x="8" y="24" width="176" height="30" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.45"/>
<text x="20" y="43" font-size="12" fill="#ffffff">cron fires, prompt sent</text>
<rect x="216" y="24" width="176" height="30" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.45"/>
<text x="228" y="43" font-size="12" fill="#ffffff">cron fires, inputData: {}</text>
<line x1="96" y1="54" x2="96" y2="78" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#sched-arrow)"/>
<line x1="304" y1="54" x2="304" y2="78" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#sched-arrow)"/>
<rect x="8" y="80" width="176" height="124" rx="6" fill="#d84835" fill-opacity="0.09" stroke="#d84835" stroke-opacity="0.7"/>
<text x="20" y="100" font-size="11" font-weight="600" letter-spacing="1" fill="#d84835">AGENT · MODEL</text>
<text x="20" y="118" font-size="12" fill="#ffffff">Beacon runs the whole job</text>
<rect x="20" y="130" width="152" height="26" rx="4" fill="none" stroke="currentColor" stroke-opacity="0.4"/>
<text x="28" y="147" font-size="10" fill="currentColor">getWebsiteAnalyticsSnapshot</text>
<rect x="20" y="164" width="152" height="26" rx="4" fill="#d84835" fill-opacity="0.18" stroke="#d84835"/>
<text x="28" y="181" font-size="10" fill="#ffffff">postSlackMessage (mutation)</text>
<rect x="216" y="80" width="176" height="54" rx="6" fill="#d84835" fill-opacity="0.09" stroke="#d84835" stroke-opacity="0.7"/>
<text x="228" y="100" font-size="11" font-weight="600" letter-spacing="1" fill="#d84835">STEP 1 · MODEL</text>
<text x="228" y="120" font-size="12" fill="#ffffff">generate, read-only tools only</text>
<line x1="304" y1="134" x2="304" y2="152" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#sched-arrow)"/>
<rect x="216" y="154" width="176" height="50" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.6"/>
<text x="228" y="174" font-size="11" font-weight="600" letter-spacing="1" fill="currentColor">STEP 2 · CODE</text>
<text x="228" y="193" font-size="12" fill="#ffffff">sdk.channel().post(report)</text>
<line x1="96" y1="204" x2="96" y2="254" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#sched-arrow)"/>
<text x="104" y="233" font-size="10" fill="currentColor">model decides to post</text>
<line x1="304" y1="204" x2="304" y2="254" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#sched-arrow)"/>
<text x="312" y="233" font-size="10" fill="currentColor">code posts</text>
<rect x="8" y="256" width="176" height="30" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.45"/>
<text x="20" y="275" font-size="12" fill="#ffffff">Slack channel</text>
<rect x="216" y="256" width="176" height="30" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.45"/>
<text x="228" y="275" font-size="12" fill="#ffffff">Slack channel</text>
</svg>
<figcaption style="margin-top:0.75rem;font-size:0.9rem;color:#928c80;">The difference is one tool. An agent schedule needs a posting tool inside the model's toolset. The workflow keeps posting in code.</figcaption>
</figure>
<p>A workflow lets me split the job in two:</p>
<ol>
<li>Generate the report. The model does this with a precise prompt and read-only tools.</li>
<li>Deliver the report. Plain code does this, with no model involved.</li>
</ol>
<p>If you&rsquo;ve read my <a href="/posts/2026-06-26-agent-loops-vs-workflows-boundary/">agent loops vs. workflows</a> post, this is the same boundary applied to a recurring job. Use the model for judgment, the workflow for control, and deterministic code for side effects.</p>
<h2 id="declaring-the-schedule-on-the-workflow">Declaring the Schedule on the Workflow</h2>
<p>Here is the shape of it, trimmed from <code>src/mastra/workflows/weekly-website-performance.ts</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="c1">// src/mastra/workflows/weekly-website-performance.ts
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="kr">import</span> <span class="p">{</span> <span class="nx">createStep</span><span class="p">,</span> <span class="nx">createWorkflow</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@mastra/core/workflows&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">z</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zod&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">weeklyReportInputSchema</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="kt">object</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">firedAt</span>: <span class="kt">z.string</span><span class="p">().</span><span class="nx">datetime</span><span class="p">().</span><span class="nx">optional</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">generatedReportSchema</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="kt">object</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">text</span>: <span class="kt">z.string</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">  <span class="nx">channelId</span>: <span class="kt">z.string</span><span class="p">().</span><span class="nx">regex</span><span class="p">(</span><span class="sr">/^[CG][A-Z0-9]{8,}$/</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">deliveryResultSchema</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="kt">object</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">delivered</span>: <span class="kt">z.literal</span><span class="p">(</span><span class="kc">true</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="nx">channelId</span>: <span class="kt">z.string</span><span class="p">().</span><span class="nx">regex</span><span class="p">(</span><span class="sr">/^[CG][A-Z0-9]{8,}$/</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kr">const</span> <span class="nx">weeklyWebsitePerformanceReportWorkflow</span> <span class="o">=</span> <span class="nx">createWorkflow</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">id</span><span class="o">:</span> <span class="s1">&#39;weekly-website-performance-report&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">inputSchema</span>: <span class="kt">weeklyReportInputSchema</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">outputSchema</span>: <span class="kt">deliveryResultSchema</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">schedule</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">cron</span><span class="o">:</span> <span class="s1">&#39;0 9 * * 1&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">timezone</span><span class="o">:</span> <span class="s1">&#39;America/New_York&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">inputData</span><span class="o">:</span> <span class="p">{},</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">generateWeeklyWebsitePerformanceReportStep</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">deliverWeeklyWebsitePerformanceReportStep</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nx">commit</span><span class="p">()</span>
</span></span></code></pre></div><figure class="post-diagram" style="margin:2rem 0 2.5rem;">
  <svg viewBox="0 0 400 560" role="img" aria-label="The scheduled workflow: the cron fires, the model generates the report in step one, a deterministic evidence contract accepts or rejects it, and code posts the accepted report to Slack in step two." style="max-width:400px;width:100%;height:auto;display:block;margin:0 auto;font-family:inherit;color:#c9c5bc;">
    <defs>
      <marker id="wf-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
        <path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
      </marker>
    </defs>
    <rect x="8" y="12" width="280" height="60" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.45"/>
    <text x="22" y="36" font-size="13" font-weight="600" fill="#ffffff">Schedule fires</text>
    <text x="22" y="56" font-size="12" fill="currentColor">cron 0 9 * * 1, America/New_York</text>
    <line x1="148" y1="72" x2="148" y2="118" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#wf-arrow)"/>
    <text x="160" y="100" font-size="11" fill="currentColor">inputData: {}</text>
    <rect x="8" y="120" width="280" height="78" rx="6" fill="#d84835" fill-opacity="0.09" stroke="#d84835" stroke-opacity="0.7"/>
    <text x="22" y="142" font-size="11" font-weight="600" letter-spacing="1" fill="#d84835">STEP 1 · MODEL</text>
    <text x="22" y="163" font-size="13" font-weight="600" fill="#ffffff">Generate the report</text>
    <text x="22" y="184" font-size="12" fill="currentColor">Beacon, snapshot forced first, read-only memory</text>
    <line x1="148" y1="198" x2="148" y2="244" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#wf-arrow)"/>
    <text x="160" y="226" font-size="11" fill="currentColor">text + tool calls</text>
    <rect x="8" y="246" width="280" height="78" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.6" stroke-dasharray="5 4"/>
    <text x="22" y="268" font-size="11" font-weight="600" letter-spacing="1" fill="currentColor">CODE · GATE</text>
    <text x="22" y="289" font-size="13" font-weight="600" fill="#ffffff">Evidence contract</text>
    <text x="22" y="310" font-size="12" fill="currentColor">one call, exact dates, finite GA4 totals</text>
    <line x1="288" y1="285" x2="318" y2="285" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#wf-arrow)"/>
    <text x="296" y="277" font-size="10" fill="currentColor">fail</text>
    <rect x="320" y="261" width="74" height="48" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.35"/>
    <text x="357" y="281" font-size="12" text-anchor="middle" fill="#ffffff">throw</text>
    <text x="357" y="298" font-size="10" text-anchor="middle" fill="currentColor">nothing posted</text>
    <line x1="148" y1="324" x2="148" y2="370" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#wf-arrow)"/>
    <text x="160" y="352" font-size="11" fill="currentColor">pass: { text, channelId }</text>
    <rect x="8" y="372" width="280" height="78" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.6"/>
    <text x="22" y="394" font-size="11" font-weight="600" letter-spacing="1" fill="currentColor">STEP 2 · CODE</text>
    <text x="22" y="415" font-size="13" font-weight="600" fill="#ffffff">Deliver the report</text>
    <text x="22" y="436" font-size="12" fill="currentColor">sanitize mentions, Channels SDK .post()</text>
    <line x1="148" y1="450" x2="148" y2="496" stroke="currentColor" stroke-opacity="0.6" marker-end="url(#wf-arrow)"/>
    <text x="160" y="478" font-size="11" fill="currentColor">new top-level message</text>
    <rect x="8" y="498" width="280" height="56" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.45"/>
    <text x="22" y="520" font-size="13" font-weight="600" fill="#ffffff">Slack channel</text>
    <text x="22" y="540" font-size="11" fill="currentColor">ID validated from env before step 1 runs</text>
  </svg>
  <figcaption style="margin-top:0.75rem;font-size:0.9rem;color:#928c80;">The model touches one step. Everything that can post to Slack is code, and the evidence contract sits between them.</figcaption>
</figure>
<p>The <code>schedule</code> field is the entire scheduling configuration. Nothing else calls <code>schedules.create()</code>, seeds a row, or hooks startup. When Mastra boots with a storage adapter configured, it reads the declared schedule off every registered workflow, writes a row with the derived ID <code>wf_weekly-website-performance-report</code>, and enables its scheduler worker automatically because at least one workflow declares a schedule.</p>
<p>Mastra reconciles that row on every boot rather than inserting it once. I read through the sync logic in <code>@mastra/core</code> to confirm what it touches. On a later boot, if the cron or timezone in code changed, Mastra patches those fields and recomputes the next fire time. It never touches <code>status</code>. If you pause the schedule from Studio, a restart leaves it paused. Remove the <code>schedule</code> field from the workflow and the row is deleted on the next boot.</p>
<p>You do need storage for this. Without a storage adapter, Mastra never starts the scheduler at all, and the adapter has to support concurrent updates (libSQL and Postgres both do). An in-memory store will run the schedule but loses the row on restart. I&rsquo;m on Postgres. Once it&rsquo;s running, the schedule shows up in Mastra Studio under <code>/workflows/schedules</code> with pause, resume, and run-now controls.</p>
<h2 id="step-1-generate-with-beacon-itself">Step 1: Generate With Beacon Itself</h2>
<p>The generation step calls Beacon itself, the same agent I talk to in Slack, with its full analytics toolset. My first two versions used a separate stripped-down agent for this job. I&rsquo;ll get to why that went away. Three decisions shape the call.</p>
<p><strong>Memory for judgment, not for evidence.</strong> My first version of this agent had no memory at all, and I liked the clean slate. The problem is that a clean slate throws away the one thing I&rsquo;d want a Monday report to know: what I&rsquo;m working on. If I&rsquo;ve been running a Google Ads test all week, I want the report to lead with the campaign, not bury it under the same five headline numbers. So the scheduled run uses Beacon&rsquo;s website memory, but read-only and narrow. It gets the curated working memory (goals, experiments, caveats, preferences) and nothing else. Recent messages, semantic recall, and observational memory are all off, and the run never writes its report back. The working-memory template also refuses to hold numbers. It says, in so many words, never retain dates, metric values, percentages, deltas, ranks, counts, or quantitative conclusions. Memory can shape what the report emphasizes. It cannot be where a number came from.</p>
<p><strong>The snapshot comes first.</strong> A <code>prepareStep</code> hook forces <code>getWebsiteAnalyticsSnapshot</code> on the first model step, then hands tool choice back to Beacon. In one of the early live runs Beacon skipped the snapshot entirely. Forcing the first step closed that door. After it, Beacon can reach for its other analytics tools if the report calls for them, and since every analytics tool it has is read-only, I&rsquo;m fine with that.</p>
<p><strong>The gate is at the tool boundary, not the prose.</strong> The workflow doesn&rsquo;t trust the report text on its own. Before delivery it checks the evidence Beacon collected, which I&rsquo;ll cover below. What it deliberately does not do is parse the report.</p>
<p>The step itself computes the date range, builds the prompt, and runs Beacon in the website memory scope:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="c1">// src/mastra/workflows/weekly-website-performance.ts
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="kr">export</span> <span class="kd">function</span> <span class="nx">completedWeeklyReportRanges</span><span class="p">(</span><span class="nx">firedAt</span>: <span class="kt">Date</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">runtimeDate</span> <span class="o">=</span> <span class="nx">runtimeTodayIsoDate</span><span class="p">(</span><span class="nx">firedAt</span><span class="p">,</span> <span class="s1">&#39;America/New_York&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">runtimeWeekday</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Date</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">runtimeDate</span><span class="si">}</span><span class="sb">T00:00:00Z`</span><span class="p">).</span><span class="nx">getUTCDay</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">daysSinceMonday</span> <span class="o">=</span> <span class="p">(</span><span class="nx">runtimeWeekday</span> <span class="o">+</span> <span class="mi">6</span><span class="p">)</span> <span class="o">%</span> <span class="mi">7</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">currentEndDate</span> <span class="o">=</span> <span class="nx">addIsoDays</span><span class="p">(</span><span class="nx">runtimeDate</span><span class="p">,</span> <span class="o">-</span><span class="p">(</span><span class="nx">daysSinceMonday</span> <span class="o">+</span> <span class="mi">1</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">currentStartDate</span> <span class="o">=</span> <span class="nx">addIsoDays</span><span class="p">(</span><span class="nx">currentEndDate</span><span class="p">,</span> <span class="o">-</span><span class="mi">6</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">currentPeriod</span><span class="o">:</span> <span class="p">{</span> <span class="nx">startDate</span>: <span class="kt">currentStartDate</span><span class="p">,</span> <span class="nx">endDate</span>: <span class="kt">currentEndDate</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nx">priorPeriod</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">startDate</span>: <span class="kt">addIsoDays</span><span class="p">(</span><span class="nx">currentStartDate</span><span class="p">,</span> <span class="o">-</span><span class="mi">7</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="nx">endDate</span>: <span class="kt">addIsoDays</span><span class="p">(</span><span class="nx">currentEndDate</span><span class="p">,</span> <span class="o">-</span><span class="mi">7</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">prepareWeeklyReportGenerationStep</span><span class="p">({</span> <span class="nx">stepNumber</span> <span class="p">}</span><span class="o">:</span> <span class="p">{</span> <span class="nx">stepNumber</span>: <span class="kt">number</span> <span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">stepNumber</span> <span class="o">===</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="o">?</span> <span class="p">{</span> <span class="nx">toolChoice</span><span class="o">:</span> <span class="p">{</span> <span class="kr">type</span><span class="o">:</span> <span class="s1">&#39;tool&#39;</span> <span class="kr">as</span> <span class="kr">const</span><span class="p">,</span> <span class="nx">toolName</span><span class="o">:</span> <span class="s1">&#39;getWebsiteAnalyticsSnapshot&#39;</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">:</span> <span class="p">{</span> <span class="nx">toolChoice</span><span class="o">:</span> <span class="s1">&#39;auto&#39;</span> <span class="kr">as</span> <span class="kr">const</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kr">const</span> <span class="nx">generateWeeklyWebsitePerformanceReportStep</span> <span class="o">=</span> <span class="nx">createStep</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">id</span><span class="o">:</span> <span class="s1">&#39;generate-weekly-website-performance-report&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">inputSchema</span>: <span class="kt">weeklyReportInputSchema</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">outputSchema</span>: <span class="kt">generatedReportSchema</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">execute</span>: <span class="kt">async</span> <span class="p">({</span> <span class="nx">inputData</span><span class="p">,</span> <span class="nx">mastra</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">firedAt</span> <span class="o">=</span> <span class="nx">inputData</span><span class="p">.</span><span class="nx">firedAt</span> <span class="o">?</span> <span class="k">new</span> <span class="nb">Date</span><span class="p">(</span><span class="nx">inputData</span><span class="p">.</span><span class="nx">firedAt</span><span class="p">)</span> <span class="o">:</span> <span class="k">new</span> <span class="nb">Date</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">ranges</span> <span class="o">=</span> <span class="nx">completedWeeklyReportRanges</span><span class="p">(</span><span class="nx">firedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">channelId</span> <span class="o">=</span> <span class="nx">weeklyReportSlackChannelId</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">requestContext</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">RequestContext</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="nx">markBeaconWebsiteMemoryScope</span><span class="p">(</span><span class="nx">requestContext</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">mastra</span><span class="p">.</span><span class="nx">getAgent</span><span class="p">(</span><span class="s1">&#39;beacon&#39;</span><span class="p">).</span><span class="nx">generate</span><span class="p">(</span><span class="nx">buildWeeklyWebsitePerformancePrompt</span><span class="p">(</span><span class="nx">firedAt</span><span class="p">),</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">prepareStep</span>: <span class="kt">prepareWeeklyReportGenerationStep</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">requestContext</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">memory</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">resource</span>: <span class="kt">BEACON_WEBSITE_RESOURCE_ID</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">thread</span>: <span class="kt">BEACON_WEEKLY_REPORT_MEMORY_THREAD_ID</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">options</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nx">readOnly</span>: <span class="kt">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nx">lastMessages</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nx">semanticRecall</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nx">observationalMemory</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">snapshotFailures</span> <span class="o">=</span> <span class="nx">weeklySnapshotContractFailures</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">ranges</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">snapshotFailures</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="sb">`Weekly website performance report evidence failed: </span><span class="si">${</span><span class="nx">snapshotFailures</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s1">&#39;; &#39;</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">{</span> <span class="nx">text</span>: <span class="kt">result.text</span><span class="p">,</span> <span class="nx">channelId</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span></code></pre></div><h3 id="dates-in-code-not-in-the-prompt">Dates in Code, Not in the Prompt</h3>
<p>When I ask Beacon a question in Slack, it resolves &ldquo;the past month&rdquo; itself from a runtime date it&rsquo;s given. That works in conversation because I&rsquo;m there to catch a bad interpretation. On a schedule, nobody is watching.</p>
<p>So the step computes the most recently completed Monday-through-Sunday range in <code>America/New_York</code> and passes the model exact ISO dates. The prompt says &ldquo;Prepare the weekly website performance report for 2026-08-17 through 2026-08-23&rdquo; and &ldquo;call getWebsiteAnalyticsSnapshot exactly once with these arguments.&rdquo; The model has nothing to interpret.</p>
<p>A nice property falls out of this. The schedule passes <code>inputData: {}</code>, so <code>firedAt</code> defaults to the time the step actually runs. If the runtime was down at 9:00 Monday and the job fires late, or I trigger it by hand on Wednesday, the range is still the previous complete week. Any time from Monday to Sunday resolves to the same answer.</p>
<h3 id="validate-the-destination-before-spending-tokens">Validate the Destination Before Spending Tokens</h3>
<p><code>weeklyReportSlackChannelId()</code> reads <code>BEACON_WEEKLY_REPORT_SLACK_CHANNEL_ID</code> from the environment and checks that it looks like a raw Slack channel ID. It runs before the model call. A misconfigured destination fails in milliseconds instead of after a GA4 pull and a model generation. The validated ID then rides through the workflow as typed step output, so the delivery step never reads config on its own and the model never sees it.</p>
<p>There are two channel IDs in the environment now, and they do different jobs. <code>BEACON_WEEKLY_REPORT_SLACK_CHANNEL_ID</code> is where the report goes. <code>BEACON_WEBSITE_SLACK_CHANNEL_ID</code> is the channel whose conversations share Beacon&rsquo;s website memory, and it stays fixed even when I redirect delivery somewhere else. Keeping them separate is what lets a canary run post to a test channel without teaching Beacon that test-channel conversations are about my website.</p>
<h3 id="an-evidence-contract-before-delivery">An Evidence Contract Before Delivery</h3>
<p>The model&rsquo;s text is not trusted on its own. The <code>weeklySnapshotContractFailures</code> check inspects the generation result and requires all of the following:</p>
<ul>
<li>exactly one call to <code>getWebsiteAnalyticsSnapshot</code></li>
<li>with <code>startDate</code> and <code>endDate</code> matching the computed week and <code>includePriorPeriod: true</code></li>
<li>and a successful tool result whose <code>data.startDate</code>, <code>data.endDate</code>, and <code>data.priorPeriod</code> match the same dates, with <code>source: 'ga4'</code>, a read-only mutation policy on the envelope, and finite traffic totals for both periods</li>
</ul>
<p>If any of that is missing, the step throws and nothing is posted. A report that reads well but wasn&rsquo;t backed by the right tool call for the right week fails the run. The same contract function runs in the eval suite, so the runtime check and the regression test can&rsquo;t drift apart.</p>
<p>For a while this contract did more. I had the prompt dictate a fixed bullet shape for the five headline metrics, and the contract parsed the report text and checked every number and comparison against the snapshot totals. It was about 200 lines of regex and tolerances, and it felt rigorous. Then a live run produced a correct, source-backed report and the parser rejected it over wording. I was maintaining a grammar for the model&rsquo;s prose, and the grammar was what kept failing, not the model. The gate belongs at the tool boundary. Prove the right evidence was collected for the right week, then let Beacon write the report the way it writes one in Slack. The prompt still says to base every number on the snapshot, and the memory template still refuses to store any, so the only place a number can come from is the call the contract just verified.</p>
<h2 id="step-2-deliver-with-code">Step 2: Deliver With Code</h2>
<p>The delivery step has no model in it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="c1">// src/mastra/workflows/weekly-website-performance.ts
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">sanitizeScheduledSlackReport</span><span class="p">(</span><span class="nx">text</span>: <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">report</span> <span class="o">=</span> <span class="nx">text</span><span class="p">.</span><span class="nx">trim</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">report</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s1">&#39;Weekly website performance report is empty&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">report</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/&lt;(?=[!@#])([^&gt;\n]+)&gt;/g</span><span class="p">,</span> <span class="s1">&#39;&amp;lt;$1&gt;&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kr">const</span> <span class="nx">deliverWeeklyWebsitePerformanceReportStep</span> <span class="o">=</span> <span class="nx">createStep</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">id</span><span class="o">:</span> <span class="s1">&#39;deliver-weekly-website-performance-report&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">inputSchema</span>: <span class="kt">generatedReportSchema</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">outputSchema</span>: <span class="kt">deliveryResultSchema</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">execute</span>: <span class="kt">async</span> <span class="p">({</span> <span class="nx">inputData</span><span class="p">,</span> <span class="nx">mastra</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">report</span> <span class="o">=</span> <span class="nx">sanitizeScheduledSlackReport</span><span class="p">(</span><span class="nx">inputData</span><span class="p">.</span><span class="nx">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">channels</span> <span class="o">=</span> <span class="nx">mastra</span><span class="p">.</span><span class="nx">getAgent</span><span class="p">(</span><span class="s1">&#39;beacon&#39;</span><span class="p">).</span><span class="nx">getChannels</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">channels</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s1">&#39;Beacon Slack Channels SDK is not initialized&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">await</span> <span class="nx">channels</span><span class="p">.</span><span class="nx">initialize</span><span class="p">(</span><span class="nx">mastra</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">sdk</span> <span class="o">=</span> <span class="nx">channels</span><span class="p">.</span><span class="nx">sdk</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">sdk</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s1">&#39;Beacon Slack Channels SDK is not initialized&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">await</span> <span class="nx">sdk</span><span class="p">.</span><span class="nx">channel</span><span class="p">(</span><span class="sb">`slack:</span><span class="si">${</span><span class="nx">inputData</span><span class="p">.</span><span class="nx">channelId</span><span class="si">}</span><span class="sb">`</span><span class="p">).</span><span class="nx">post</span><span class="p">(</span><span class="nx">report</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">{</span> <span class="nx">delivered</span>: <span class="kt">true</span> <span class="kr">as</span> <span class="kr">const</span><span class="p">,</span> <span class="nx">channelId</span>: <span class="kt">inputData.channelId</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span></code></pre></div><p>Beacon is already connected to Slack through <a href="https://mastra.ai/docs/channels">Mastra Channels</a>, which handles inbound mentions and replies. Channels doesn&rsquo;t hand the agent a &ldquo;post a message&rdquo; tool, and I didn&rsquo;t want to add one. But the Channels SDK is available to code, and <code>sdk.channel('slack:C…').post(text)</code> writes a new top-level message to a channel. The delivery step borrows Beacon&rsquo;s existing connection to send it.</p>
<p>The report agent is told not to use Slack mentions, and the output processor normalizes Markdown into Slack mrkdwn. Still, the model could emit <code>&lt;!channel&gt;</code> or <code>&lt;@U…&gt;</code> and page everyone in the channel. The regex neutralizes control mentions and leaves ordinary links alone.</p>
<h2 id="rolling-it-out">Rolling It Out</h2>
<p>A schedule that posts to a real channel is not something I wanted to enable and hope for. The rollout used the fact that a paused schedule can still be run by hand:</p>
<ol>
<li>Boot the runtime once so Mastra writes the schedule row. Pause it in Studio and confirm the status persists as <code>paused</code>.</li>
<li>Point <code>BEACON_WEEKLY_REPORT_SLACK_CHANNEL_ID</code> at a test channel, leave the memory channel alone, and restart. Confirm the schedule stays paused.</li>
<li>Trigger one immediate run through the schedule API. Confirm a new top-level message lands in the test channel.</li>
<li>Swap the environment variable to the production channel, restart, confirm Beacon&rsquo;s Slack app is a member of that channel, and only then resume the schedule.</li>
</ol>
<p>The first real run is scheduled for the following Monday at 9:00 Eastern. I got the full workflow exercised end to end, including the actual Slack post, without ever opening the recurring path to production before the destination was confirmed.</p>
<h2 id="what-i-would-watch">What I Would Watch</h2>
<p>Two open edges I haven&rsquo;t addressed yet.</p>
<p><strong>Failures are quiet.</strong> A failed run shows up in Studio and in the workflow trace, but nothing tells me about it. A transient GA4 error on the forced first step will fail the evidence check with no retry. For a weekly report that&rsquo;s a tolerable first version. The next change is a <code>retryConfig</code> on the generation step and a deterministic &ldquo;report failed&rdquo; post so a missed Monday is visible in the same channel.</p>
<p><strong>Delivery is coupled to the in-process Channels SDK.</strong> The workflow reaches into the running Beacon agent for its Slack connection. That already bit me once: a manual run reached the delivery step before Channels had finished connecting, which is why the step now awaits <code>channels.initialize(mastra)</code> before touching the SDK. It&rsquo;s fine while the scheduler runs in the same process as the agent. If I ever move the scheduler to a standalone Mastra worker, that step will fail closed and need its own Slack client.</p>
<h2 id="where-else-this-applies">Where Else This Applies</h2>
<p>This is one use case for scheduling agents. The same shape works for any recurring job where a model should summarize and code should act. Some others that come to mind:</p>
<ul>
<li>A nightly runtime health digest. The diagnostics tools already exist for on-demand use. The change is a schedule and a delivery step.</li>
<li>Weekly Search Console query drift, flagging queries that gained or lost position.</li>
<li>A Monday content-pipeline status pulled from Notion, listing what&rsquo;s scheduled and what&rsquo;s blocked.</li>
</ul>
<p>If the output needs to go somewhere, keep that step out of the model&rsquo;s hands.</p>
<hr>
<p>If you&rsquo;re building recurring agent jobs with Mastra and working out where the model should stop and code should take over, I help teams design that boundary in production. <a href="/mastra-consulting/">See how I help Mastra teams</a>.</p>
<h2 id="further-reading">Further Reading</h2>
<ul>
<li><a href="/posts/2026-06-26-agent-loops-vs-workflows-boundary/">Agent Loops vs. Workflows: The Boundary That Makes AI Reliable</a> is the general version of the judgment, control, and side-effect split this post applies to a scheduled job.</li>
<li><a href="/posts/2026-05-27-human-in-the-loop-agent-approvals-a-mastra-pattern/">Human-in-the-Loop Agent Approvals: A Mastra Pattern</a> covers the other way to keep mutations out of the model&rsquo;s hands when a human does need to be in the loop.</li>
<li><a href="/posts/2026-03-06-build-personal-ai-assistant/">How I Built a Personal AI Assistant with Mastra</a> is where I first scheduled agent work by polling a task table. The declarative <code>schedule</code> field replaces that.</li>
<li><a href="https://mastra.ai/docs/workflows/scheduled-workflows">Mastra Scheduled Workflows</a> is the official reference for the <code>schedule</code> field and schedule management.</li>
<li><a href="https://mastra.ai/docs/harness/schedules">Mastra Schedules</a> documents the agent-level alternative.</li>
<li><a href="https://mastra.ai/docs/channels">Mastra Channels</a> covers the Slack integration the delivery step borrows.</li>
</ul>
]]></content:encoded></item></channel></rss>