Task-email health audit
How Recoup staff check whether scheduled task emails are healthy — real reports delivered, no empty "Message from Recoup" footer-only sends — over any window. This is the standing audit for the pipeline fixed in recoupable/chat#1829 (empty-email bug, resolved 2026-07-01).
When to use
- "recoup-internal — how did last night's tasks go? What emails went out?"
- "Are we still shipping empty footer-only emails?"
- "Task email health for the last 24h / this week."
- "Did the #729 guard actually block empties in production?"
- "Is the data in this task email real, or hallucinated? Why did it fabricate?" (→ §§ 4–5)
- "Show me the exact tool calls a task run made." (→ § 4)
Background (what you're measuring)
Scheduled tasks (customer-prompt-task, Trigger.dev) each run the
runAgentWorkflow agent, which sends its report by calling POST /api/emails
from inside the sandbox. Before the 2026-07-01 fix, a malformed/empty body was
silently delivered as a footer-only "Message from Recoup" email with
success:true. The fix: POST /api/emails now rejects empty/unparseable
bodies (400) and logs every attempt to email_send_log (sent / send_failed /
rejected) with the full raw body. That log is the audit's source of truth.
Data sources
A. Task runs — Trigger.dev Management REST API (the run list + run_id)
Every scheduled task run is a Trigger.dev run of the customer-prompt-task
task (lib/trigger/createSchedule.ts),
tagged account:<accountId>. Enumerate them with the bundled script — it pages
the Management API, filters to customer-prompt-task in the window, and prints the
account array for the correlation SQL:
node scripts/fetch_task_runs.mjs --hours 24 > runs.json # add --account <uuid> for one account
Requires the PROD TRIGGER_SECRET_KEY — a tr_dev_* key returns 0 prod
runs (the #1 gotcha; the script warns you). Pull it without clobbering your .env,
from the recoupable/api repo (linked to recoup/api):
vercel env pull /tmp/prod.env --environment=production --yes
export TRIGGER_SECRET_KEY=$(grep '^TRIGGER_SECRET_KEY=' /tmp/prod.env | cut -d= -f2- | tr -d '"')
# … run the script …
rm /tmp/prod.env # scrub the secrets when done
runs.json rows are { run_id, account, status, createdAt, finishedAt }. For the
exact per-run prompt, runs.retrieve(runId) (@trigger.dev/sdk) has
payload.prompt; but scheduled_actions.prompt per account (in the SQL below) is
cheaper and usually sufficient.
B. Emails + agent activity — Supabase (project godremdqwajrwazhbrue)
email_send_log— the send-attempt log. One row per/api/emailsattempt:status(sent|send_failed|rejected),account_id,chat_id,resend_id,raw_body(full request body),created_at. Exists only from 2026-06-30 (when api#731 shipped).chats/chat_messages/sessions— every run creates a chat; the agent's bash/tool activity (how it sent) is inchat_messages.parts.scheduled_actions— the scheduled-task definitions (title, prompt, account_id). Resolve a run's prompt/title without aruns.retrievecall.
⚠️ Do NOT use
workflow_runsorscheduled_actions.last_run/next_run— stale (not maintained by the current scheduler;workflow_runsstopped updating 2026-05-27). Run history is Trigger.dev; activity ischats/chat_messages.
Query Supabase via the MCP (execute_sql) or psql/DATABASE_URL. Read-only.
The audit (run these, newest-first)
Substitute the window in interval '24 hours'. Always exclude test noise —
sends to sweetmantech@gmail.com and preview-auth-probe bodies are staff test
runs, not customer traffic.
1. Headline — tasks run, delivered vs. blocked, guard-block rate, forced retries
tasks_run = the number of customer-prompt-task runs in the window — count
them from the Trigger.dev run list (§ A). The remaining columns come from
email_send_log:
with w as (
select status,
substring(raw_body from '"to"\s*:\s*\[\s*"([^"]+)"') as rcpt,
created_at
from public.email_send_log
where created_at > now() - interval '24 hours'
and raw_body not like '%sweetmantech@gmail.com%'
and raw_body not like '%preview-auth-probe%'
)
select
count(*) filter (where status='sent') as delivered,
count(*) filter (where status='rejected') as blocked_empty,
round(100.0*count(*) filter (where status='rejected')/nullif(count(*),0),0) as pct_blocked,
count(distinct rcpt) filter (where status='sent') as recipients_served,
count(*) filter (where status='rejected'
and rcpt in (select rcpt from w where status='sent')) as blocked_then_delivered;
tasks_run≠ email tasks — segment the noise. Manycustomer-prompt-taskruns are not email tasks: profile-sync/maintenance (one account fired 33 runs in 24h), health-checks, and misconfigured prompts (literally"22", or"New task — replace with your instructions."). Reporttasks_run(all runs) andemail_tasks(prompt mentions email/send and the account resolves a recipient) separately, and list the non-email / high-frequency / misconfigured ones as a short "ops noise" callout — otherwise the delivery rate is judged against a polluted denominator (the first real run read "6 of 90", but ~33 of those 90 were one maintenance task).
2. Per-run table — one row per Trigger.dev run (the main deliverable)
Columns: run_id · recipient · prompt (truncated) · subject ·
body (kb). Paste the array[...]::uuid[] that the script printed into this
one correlation query (returns recipient + prompt + delivered subject/body per
account — this exact query is validated, mind the inline gotchas):
with racct as (
select unnest( /* paste the account array from fetch_task_runs.mjs here */ ) as account_id
)
select substr(ra.account_id::text, 1, 8) as acct,
ae.email as recipient,
left(sa.prompt, 60) as prompt,
es.subject,
es.body_kb
from racct ra
-- account_emails has NO created_at → limit 1, no order
left join lateral (select email from public.account_emails
where account_id = ra.account_id limit 1) ae on true
left join lateral (select prompt from public.scheduled_actions
where account_id = ra.account_id order by updated_at desc limit 1) sa on true
-- the delivered email: newest 'sent' row for the account in the window
left join lateral (
select substring(raw_body from '"subject"\s*:\s*"([^"]{0,60})') as subject,
round(length(raw_body) / 1024.0, 1) as body_kb
from public.email_send_log
where account_id = ra.account_id and status = 'sent'
and created_at > now() - interval '24 hours'
order by created_at desc limit 1
) es on true
order by (es.subject is not null) desc, ra.account_id; -- delivered first
Then join runs.json to this result by account to fill run_id. Notes:
subject is null⇒ the account delivered no email in the window (ran but didn't send, or its send was a blocked empty —rejectedrows carry a nullaccount_id, so they don't show here; the § 1 headline counts them).- An account with >1 run in the window (a task firing hourly) maps to one
account row here — attribute the delivered email to the run whose
finishedAtis nearest the send.
Render:
| run_id | recipient | prompt | subject | body (kb) |
|---|---|---|---|---|
run_abc… |
seb.simone@wmg.com | "Daily social trends for PinkPanth…" | Daily Social Trends: PinkPantheress – July 1 | 15.2 |
run_def… |
Laszlo.Bihary@gmail.com | "Leonardo daily social trends…" | — (blocked, no send) | — |
Join caveat.
email_send_log.chat_idis null (agents don't pass it), so the run→email link is account + timing, not an exact key — reliable when an account fires one task per window, ambiguous when several fire together. Threading a realchat_idinto/api/emails(the footer-404 follow-up) makesrun_id → emailexact.
3. Delivered reports (the real emails that went out)
select
substring(raw_body from '"subject"\s*:\s*"([^"]{0,70})') as subject,
substring(raw_body from '"to"\s*:\s*\[\s*"([^"]+)"') as recipient,
account_id, to_char(created_at,'MM-DD HH24:MI') as at
from public.email_send_log
where status='sent' and created_at > now() - interval '24 hours'
and raw_body not like '%sweetmantech@gmail.com%'
order by created_at desc;
4. Per-run tool-call trace — why an email is empty / rich / hallucinated
To debug a single run (what it actually fetched, whether it fabricated), pull its
tool calls from chat_messages.
First, find the run's chat. email_send_log.chat_id is null, so locate the chat
by recipient (or a subject marker):
select chat_id, min(created_at) started
from public.chat_messages
where created_at > now() - interval '24 hours'
and parts::text like '%recipient@domain.com%'
group by chat_id order by started desc;
Then extract the tool calls. ⚠️ chat_messages.parts is an object
{id, role, parts:[…]}, not a bare array — unnest parts::jsonb->'parts'. A run
is usually one assistant message holding every part:
select row_number() over (order by ord) as n,
p->>'type' as tool,
left(coalesce(p->'input'->>'command', p->'input'->>'skill', p->'input'->>'url',
p->>'text', (p->'input')::text), 200) as input_or_text,
left(coalesce(p->'output'->>'stdout', p->'output'->>'content',
(p->'output')::text, p->>'state'), 200) as result
from public.chat_messages cm,
lateral jsonb_array_elements(
case when jsonb_typeof(cm.parts::jsonb)='array' then cm.parts::jsonb
else cm.parts::jsonb->'parts' end
) with ordinality as t(p, ord)
where cm.chat_id = '<chat_id>'
and p->>'type' in ('text','tool-bash','tool-skill','tool-web_fetch','tool-write')
order by ord;
The text parts are the agent's narration — usually the smoking gun (e.g.
"the API doesn't have direct CPM metrics, I'll generate … sample data").
5. Content audit — is the reported data real? (has_hallucinated_data)
For each delivered email, set has_hallucinated_data from the run's tool calls
(§ 4): a metric is hallucinated if it isn't backed by a successful data-fetch this
run. Red flags:
- the data call errored / returned empty (
/socials→ "Artist not found",organizations: []) yet the email still reports numbers; - no
web_fetchin a trends/research task (used_web_fetch=false); - the HTML/narration contains "sample data" / "estimated" / "industry average" /
"realistic data" /
chart-placeholder; - the agent used the wrong id for a sub-resource (gotchas below) → 404 → no data.
Report two columns alongside the delivered table: has_hallucinated_data (bool;
mark ✅ verified vs ⚠️ inferred) and expected_data_source (where accurate
data should come from — YouTube CPM → YouTube Analytics connector; trends →
web_fetch + platform trending; followers/streams → Recoup socials / Research).
Id gotchas when spot-checking real data (skip these and you'll log false "no data"):
/api/artists/{id}/*sub-resources key on theaccount_id, not the list's top-levelid. And socials are embedded in/api/artists(account_socials) — check there before calling/socialsat all.POST /api/socials/{id}/scrapekeys onsocial_id, not theidfield.
How to read it
delivered= real reports that reached customers. Spot-check subjects in query 2 — they should carry a real title + the correct date (a wrong year, e.g. "2024", is a stale/hallucinated-date content bug worth flagging).blocked_empty= empty/malformed sends the #729 guard stopped. Every one of these would have been a footer-only "Message from Recoup" email delivered to a real customer before 2026-07-01. This is the guard working, not a failure.blocked_then_delivered= the agent's first attempt was empty (blocked), then it retried into a real send to the same recipient — the guard's 400 forced a correction.pct_blocked= the underlying empty-generation rate; the guard makes it harmless.
Caveats
Classify every run
sent/correct no-send/failed— never assume no-send = failure. Some runs aren't email tasks at all, and some prompts are explicitly conditional ("if no new video, send nothing"). Read the run's finaltextpart (§ 4): an honest no-op conclusion is correct behavior.rejected≠ always guard-blocked. An expired sandbox ephemeral key (runs~20 min) logs every retry as
rejectedtoo — one run can emit dozens. Check the trace: 401 "Unauthorized" = key expiry (delivery-infra); "a non-empty html or text body is required" = a real guard block.Part counts can shrink mid-run — a workflow-step retry resets the persisted assistant message. Re-query before concluding a run's final state.
Needs the PROD
TRIGGER_SECRET_KEY— atr_dev_*key returns 0 runs; the script warns you. Pull it to a scratch file and scrub it after (see § A). Ifvercel env pullfails with an invalid-token error, the CLI login has expired — ask the operator to runvercel login(or paste the key) before proceeding.email_send_log.chat_idis null on nearly all sends — agents don't pass it. So you can't join sends to a specific run/task bychat_id; attribute by recipient and account_id (onsentrows) instead. This null also means task emails currently render no footer "continue on Recoup" link — a known follow-up.account_idis null onrejectedrows — the guard runs before auth, so a 400 has no resolved account. Attribute blocked empties by recipient/subject.Always exclude staff test traffic (
sweetmantech@gmail.com,preview-auth-probe).No
email_send_logdata before 2026-06-30.