Automation · Sales and Marketing · Product Engineering
Product update emails, turned into SEO blog post pull requests
When a product update broadcast goes out in Customer.io, an agent turns it into a detailed, search-optimized blog post in your website repo and opens a pull request for review. Nothing publishes without you.
Created by Sam Claassen from type.com
What it does
- Every product update email becomes a 6–10 minute post with how-tos, use cases, a change table and FAQs.
- Built for search and AI answers: keyword-led titles and question headings with answer-first copy.
- Checks claims against your docs and code, and lists anything it could not verify in the PR.
- Mirrors how your newest post was added, runs your checks and waits for CI before reporting.
Before you start
- Customer.io connected with an App API key.
- GitHub connected with write access to your website repository.
- Product update broadcasts named with a consistent prefix, such as "Product update".
See an example
Example output from a sample run. Unless the example says otherwise, company names and figures are sample data.
Example Space report from the agent (illustrative, not real data)
PR opened: Approve AI agent actions from Slack · github.com/acme/website/pull/412 (assigned to jdoe, CI green)
- Slug:
approve-ai-agent-actions-in-slack· primary keyword: "approve AI agent actions in Slack" - 8-minute read: 3 features with numbered how-tos, 7 use cases, a change table, a copyable prompt and 6 FAQs; every section opens with a direct answer
- 5 figures + hero and social image; QA'd at 1440 px and 390 px
- Check before publishing: "bulk approvals" is behind a feature flag that is not fully rolled out, so the post calls it "rolling out"
Reply publish to merge it.
How to use Product update emails, turned into SEO blog post pull requests
- Select Use automation to open a new conversation with the automation attached.
- Choose a space and describe any changes you want before sending the message.
- Work with the agent to connect your integrations and review the automation before turning it on.
Browse the automation instructions
---
name: "Product Update Email to SEO Blog Post"
description: "When a product update broadcast is sent from Customer.io, an agent turns it into a detailed, search-optimized blog post in your website repository and opens a pull request for review. Nothing is merged or published automatically."
connection_needs:
- "Customer.io (App API key connection, base URL https://api.customer.io): list newsletters (broadcasts) and read a newsletter's contents."
- "GitHub (full access for the Space's managed sandbox): clone the website repository, push a branch, open a pull request with gh, and read CI results."
inputs:
- "destination_space: the Space that runs the automation and receives the agent's final report"
- "destination_room: optional room (subchannel) ID in that Space for the report; empty uses the default room"
- "customerio_connection: the Customer.io App API connection"
- "newsletter_name_prefix: broadcast name prefix that marks a product update (source default: Product update)"
- "company_name: company or product name used in the post and its metadata"
- "website_repository: owner/name of the repository that holds the blog"
- "pr_assignee: GitHub username assigned to the pull request; the person who reviews and publishes"
- "branch_prefix: branch name prefix for new posts (for example product-update/)"
- "product_docs_url: documentation the agent checks feature claims against, ideally a full-text llms.txt export"
- "feature_flags_location: optional path of feature-flag definitions in the repository"
- "brand_name_rule: optional rule for how the company and product name must be written"
- "visual_standards_skill: optional handle of a skill with your blog visual standards and render toolkit"
- "blog_conventions: optional repository-specific steps for adding a post (files to register, generators, tests)"
- "blog_category: category given to these posts (source default: Product updates)"
- "blog_author: author shown on these posts (source default: <company> Team)"
- "blog_timezone: time zone for the post date (source default: America/New_York)"
---
# Product Update Email to SEO Blog Post
## Purpose
Product update emails reach existing users once and then disappear. This automation turns each one into a lasting, search- and AI-answer-optimized blog post. Every 30 minutes, code checks Customer.io for newly sent broadcasts whose name starts with the product update prefix. For each new one, an agent fetches the full email, writes a 6–10 minute post that goes far beyond the email (how-tos, use cases, limits, a change table, FAQs), adds it to the website repository the same way the newest existing post was added, runs the repository's checks, opens a pull request assigned to the reviewer, waits for CI, and reports back in the Space. **It never merges or publishes**; the reviewer does.
## Setup
Settle every binding before creating the automation. None inherits a value from the original workspace.
| Binding | Meaning and how to supply it |
| --- | --- |
| `{{input:destination_space}}` | The Space that runs the automation (its ID, `ch_…`). Its agent needs the GitHub connection with full access and a managed sandbox. The final report is posted here. |
| `{{input:destination_room}}` | Optional room ID (`sub_…`) in that Space for the report. Leave empty for the default room. |
| `{{input:customerio_connection}}` | A Customer.io Custom API connection with an **App API key** (Settings → API Credentials → App API keys; Data Pipelines keys return 401). Base URL `https://api.customer.io` (EU accounts: `https://api-eu.customer.io`), Bearer auth. Save it in `integrationAccounts`. |
| `{{input:newsletter_name_prefix}}` | Broadcasts whose name starts with this text (case-insensitive, whole word) count as product updates, for example "Product update: Slack approvals". |
| `{{input:company_name}}` | Name used in the post, title and description. |
| `{{input:website_repository}}` | `owner/name` of the blog's repository. |
| `{{input:pr_assignee}}` | GitHub username of the reviewer. |
| `{{input:branch_prefix}}` | Prefix for the post branch; the branch is `<prefix><slug>`. |
| `{{input:product_docs_url}}` | Documentation to verify claims against (a full-text `llms.txt` export works best). |
| `{{input:feature_flags_location}}` | Optional path to feature-flag definitions, so the agent can tell which features are fully rolled out. |
| `{{input:brand_name_rule}}` | Optional naming rule, for example how the product name is capitalized. |
| `{{input:visual_standards_skill}}` | Optional skill (handle) with your blog visual standards and render toolkit. Without it, the agent follows the visual style of the newest posts. When supplied, also pass it in `skillReferences`. |
| `{{input:blog_conventions}}` | Optional exact steps for adding a post in this repository (files to register, redirects or cache rules, generators, tests). Without it the agent mirrors how the newest post was added. |
| `{{input:blog_category}}`, `{{input:blog_author}}`, `{{input:blog_timezone}}` | Category, author and date time zone for the post. |
Free-text values are inserted into `String.raw` template literals in the code, so they may contain quotes but not backticks or `${`.
Create the automation in `{{input:destination_space}}` with `check_automation_readiness` (Customer.io read, GitHub full access) and `create_task` with the code below and the saved `integrationAccounts`. Create it paused and enable it after review. **The first run only records the product updates already sent**; posts are written for broadcasts sent after that.
## Triggers and execution
Code automation (`mode: "code_with_optional_agent"`, agent invocation decided by code, `failurePolicy: "pause"`) with one recurring subscription, `checkSentProductUpdates`: every 1,800 seconds (30 minutes). No model or run settings are configured in the source (agent defaults).
Stages: `checkSentProductUpdates` (code) → `work` (agent) → `finishWork` (code) → `work` again while items remain.
- `checkSentProductUpdates` → `work`: `{ current: { effectKey, instructions, context }, remaining: [same shape], output }`. `context` holds `newsletterId`, `newsletterName`, `sentAt` (ISO), `subject`, `preheader` and `emailText` (a plain-text outline of the email HTML, up to 12,000 characters).
- `work` returns `{ summary }`: its final report for the Space, or empty when messages are not allowed.
- `finishWork` posts a non-empty summary in the Space, then starts the next queued item or completes with the scan output.
## Workflow
1. **List broadcasts.** `GET /v1/newsletters?limit=50&sort=desc`. Keep newsletters whose name matches the prefix, `send_state` is `sent`, and `sent_at` is set.
2. **First run.** With no checkpoint, save every matching ID as processed and complete `{ seeded: true, alreadySent: [names] }`. No agent runs.
3. **New sends.** Otherwise, take matching IDs not yet processed, oldest send first. If none, complete `{ newProductUpdates: 0, checkedAt }`.
4. **Prepare each item.** `GET /v1/newsletters/{id}/contents`; use the first content variant's `subject`, `preheader_text` and `body`. Convert the HTML to a text outline: drop `<style>`, keep link text with its URL in parentheses, break lines after headings, paragraphs, list items, rows and divs, strip tags, decode ` ` and `&`, collapse whitespace, cap at 12,000 characters. Queue the item with effect key `product-update-blog:<id>` and mark the ID processed.
5. **Save the checkpoint** with all processed IDs, then hand the first item to the agent.
6. **Agent writes the post** following the playbook in the code: duplicate check by newsletter ID in existing post sources; fetch the full email; content, SEO and AEO rules; mirror the newest post's implementation; QA at 1440 px and 390 px; run generators, focused tests, typecheck and formatter; commit, push, open the PR assigned to the reviewer; wait for CI and fix real failures; never merge or publish.
7. **Report.** `finishWork` posts the agent's summary (PR link, title, slug, primary keyword, uploaded cover image, what the post adds, what to check before publishing, and "reply publish to merge") in the Space with effect key `agent-result:product-update-blog:<id>`, then continues with the next queued item.
## State and failure behavior
- Checkpoint `{ processedIds: [newsletter IDs] }`. The first run seeds it; it grows by one ID per product update.
- Items are marked processed **before** the agent works on them, so a failed agent run is not re-queued by later scans. With `failurePolicy: "pause"`, a failure pauses the automation for a member to review.
- The agent's duplicate check (newsletter ID in an existing post's source comment) prevents a second post for the same email.
- Unverifiable claims are left out or hedged and listed in the PR body. The agent never merges, publishes or changes anything outside its branch and PR.
- A Customer.io list response that does not match the expected shape fails the run with "Unexpected Customer.io newsletters response". A contents response that does not parse leaves `subject`, `preheader` and `emailText` null; the agent still fetches the full HTML itself.
## Integration data and capabilities
| Integration | Data and capabilities used | Relevant semantics |
| --- | --- | --- |
| Customer.io App API (Custom API) | `GET /v1/newsletters` (`limit=50`, `sort=desc`) → `newsletters[]` with `id`, `name`, `send_state`, `sent_at` (Unix seconds). `GET /v1/newsletters/{id}/contents` → `contents[]` with `id`, `subject`, `preheader_text`, `body` (HTML). | Code reads through `ctx.read.request`, which may return the integration envelope (`{ status, data }`) or the provider body. When the connection is command-based, the agent fetches contents with `run_integration_command` and `Authorization: Bearer $TYPE_CUSTOM_API_SECRET`. App API keys only. |
| GitHub (managed sandbox) | Clone, branch from `origin/main`, commit, push, `gh pr create --assignee`, read CI. | Token comes from `TYPE_GITHUB_CONNECTIONS`. |
| Type | `ctx.write.sendMessage` to the Space (and optional room). | Effect key per newsletter. |
## Reference implementation
Original provider assumptions: Customer.io App API through a Type Custom API connection, a GitHub connection usable by the Space's managed sandbox, and Type's Space messages. The source playbook described its own website repository's exact file layout; this version tells the agent to mirror the newest existing post (plus optional `blog_conventions`). **Reuse when these assumptions hold; adapt when they do not, preserving the behavior above.**
```ts
import { automation, code, complete, next, agent, agentResult, recurringEventSchema } from "@type-dot-com/sdk/automations";
import { ok } from "neverthrow";
import { TypeApiError } from "@type-dot-com/sdk";
import { type RecurringEvent } from "@type-dot-com/sdk/automations";
import { err } from "neverthrow";
import { z } from "zod";
// Setup values: replace each input placeholder below with the installer's choice before deploying.
// Free-text values sit in String.raw template literals: they may contain quotes but not backticks or "${".
// Customer.io App API connection (auth injected by Type).
const customerIo = "{{input:customerio_connection}}";
const NEWSLETTER_PREFIX = String.raw`{{input:newsletter_name_prefix}}`;
const REPORT_SPACE = "{{input:destination_space}}";
const REPORT_ROOM = "{{input:destination_room}}"; // optional; empty posts to the Space's default room
const COMPANY = String.raw`{{input:company_name}}`;
const WEBSITE_REPO = "{{input:website_repository}}";
const PR_ASSIGNEE = "{{input:pr_assignee}}";
const BRANCH_PREFIX = "{{input:branch_prefix}}";
const PRODUCT_DOCS = String.raw`{{input:product_docs_url}}`;
const FEATURE_FLAGS = String.raw`{{input:feature_flags_location}}`; // optional
const BRAND_NAME_RULE = String.raw`{{input:brand_name_rule}}`; // optional
const VISUAL_SKILL = String.raw`{{input:visual_standards_skill}}`; // optional skill handle
const BLOG_CONVENTIONS = String.raw`{{input:blog_conventions}}`; // optional repository-specific steps
const BLOG_CATEGORY = String.raw`{{input:blog_category}}`;
const BLOG_AUTHOR = String.raw`{{input:blog_author}}`;
const BLOG_TIMEZONE = "{{input:blog_timezone}}";
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const productUpdateName = new RegExp(`^${escapeRegExp(NEWSLETTER_PREFIX.trim())}\\b`, "i");
const newsletter = z.looseObject({
id: z.number(),
name: z.string(),
send_state: z.string().nullish(),
sent_at: z.number().nullish(),
});
const newsletterList = z.looseObject({ newsletters: z.array(newsletter) });
const contentList = z.looseObject({
contents: z.array(
z.looseObject({
id: z.number(),
subject: z.string().nullish(),
preheader_text: z.string().nullish(),
body: z.string().nullish(),
}),
),
});
const checkpointSchema = z.object({ processedIds: z.array(z.number()) });
/** ctx.read.request may return the integration envelope or the provider body. */
function providerBody(value: unknown): unknown {
const envelope = z
.looseObject({ status: z.number(), data: z.unknown() })
.safeParse(value);
return envelope.success ? envelope.data.data : value;
}
/** Plain-text outline of the email HTML, as evidence for the agent. */
function emailText(html: string): string {
return html
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<a [^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, "$2 ($1)")
.replace(/<\/(h[1-6]|p|li|tr|div)>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/ /g, " ")
.replace(/&/g, "&")
.replace(/[ \t]+/g, " ")
.replace(/\n\s+/g, "\n")
.trim()
.slice(0, 12000);
}
const visualRule = VISUAL_SKILL.trim()
? `Follow this Space's instructions and the ${VISUAL_SKILL} skill for every visual`
: "Follow this Space's instructions and the visual style of the newest existing blog posts for every visual";
const brandRule = BRAND_NAME_RULE.trim()
? `\n- ${BRAND_NAME_RULE} Keep in-product names users must match (for example mention handles or menu labels) verbatim.`
: "\n- Keep in-product names users must match (for example mention handles or menu labels) verbatim.";
const flagsRule = FEATURE_FLAGS.trim() ? ` and feature flags in ${FEATURE_FLAGS}` : " and feature flags";
const conventions = BLOG_CONVENTIONS.trim()
? `\n Repository-specific steps to follow exactly:\n${BLOG_CONVENTIONS.replace(/^\n+|\s+$/g, "")}`
: "";
const PLAYBOOK = `You turn a sent ${COMPANY} product update email (Customer.io broadcast) into a much more detailed, search-optimized product update blog post on the ${COMPANY} website, and open a pull request for ${PR_ASSIGNEE} to review.
Hard rules
- ${visualRule}: live-text hero with a concept unique to this post, 4-5 varied in-article figures (diagrams, close-ups, a result view; at most one full app screenshot), real brand assets only, and a visual QA pass (view every render) before the PR.${brandRule}
- Never invent product behavior. Check every feature claim against the product documentation (${PRODUCT_DOCS}) and the repository (feature code${flagsRule}). If something can't be verified, leave it out or phrase it carefully, and list it in the PR body.
- Don't merge or publish. Open the PR, assign it to ${PR_ASSIGNEE}, and stop. ${PR_ASSIGNEE} publishes after review.
- Before starting, search the repository's existing blog post sources for this newsletter's Customer.io ID in a source comment. If a post already exists for it, report that and stop.
Content (much more than the email)
- Fetch the full email HTML with the Customer.io App API: GET /v1/newsletters/{newsletterId}/contents with the Customer.io connection (use api_get, or run_integration_command with curl -H "Authorization: Bearer $TYPE_CUSTOM_API_SECRET" https://api.customer.io/v1/newsletters/{newsletterId}/contents when the connection is command-based). Use the subject, preheader, every feature section, and every linked video or post.
- The email is the outline. The post should be a 6-10 minute read: for each feature, cover what it is, who it's for, why it matters, how to use it (numbered steps), 2-3 concrete use cases, and limits or requirements. Add a summary table of every change, a copyable prompt block where it helps, takeaways, and 6 FAQs.
- Link out generously: each feature's documentation page, relevant integration pages and library listings on the website, and the videos linked in the email.
SEO
- Pick one primary keyword for the headline feature (what people would search, for example "connect <product> to Claude Code" or "granular AI integration permissions") plus 4-8 secondary keywords, and set them in keywords.
- Title: lead with the main feature in plain language (no "Product Update - MM/DD/YY" titles). Write a descriptive keyword slug, not a date slug. Description of 155 characters or fewer, containing the primary keyword and ${COMPANY}.
- Make every section title a question with an answer-first "answer" field (AEO). Include the primary keyword in the first section's answer and in at least one H2.
- category: "${BLOG_CATEGORY}", author "${BLOG_AUTHOR}", date = the email's sent date (${BLOG_TIMEZONE}). The post's source comment must cite the Customer.io newsletter ID and name, and mark any illustrative data as fictional.
Implementation (mirror the latest existing post)
1. Work in the managed sandbox checkout of ${WEBSITE_REPO}. Branch from up-to-date origin/main: ${BRANCH_PREFIX}<slug>.
2. Find how the newest blog post was added (its source file, registrations and indexes, route, redirects or cache rules, images, generated files and tests, for example from the commit that added it) and replicate every step for the new post.${conventions}
3. Put images where existing posts keep theirs: hero, social image and the figures, rendered with the toolkit the visual rules name.
4. Install dependencies if they are missing. Start the dev server to QA the new post at 1440 px and 390 px (all figures load, no horizontal overflow) and the blog index card. Blog figures may lazy-load: scroll each article image into view before checking it loaded. Dismiss cookie banners before screenshots. Stop the dev server by PID afterwards.
5. Run the repository's generators for blog indexes or stamps (re-run them after any content edit), the focused blog tests, the typecheck, and the formatter on the changed files.
6. Commit with an imperative subject. Push, then gh pr create with --assignee ${PR_ASSIGNEE} (GitHub token from TYPE_GITHUB_CONNECTIONS). The PR body covers: the source email, primary keyword, sections, figures, verification results, and the claims you couldn't verify.
7. Wait for CI. Fix real failures. Preview-deployment sync jobs are not code checks.
Final report (in this Space): the PR link, title, slug, primary keyword, the cover image (upload it), a short summary of what the post adds beyond the email, and anything ${PR_ASSIGNEE} must check before publishing (unverified claims, feature flags not fully rolled out, missing links). Tell ${PR_ASSIGNEE} to reply "publish" to merge it.`;
const requestSchema = z.object({effectKey:z.string(), instructions:z.string(), context:z.json()});
const workInput = z.object({current:requestSchema, remaining:z.array(requestSchema), output:z.json()});
const workOutput = z.object({summary:z.string()});
const workStage = agent({input:workInput, output:workOutput, instructions:`${PLAYBOOK}\n\nExecute input.current.instructions with input.current.context; leave input.remaining to later stages. Return your final Space reply in summary; the continuation posts it. Keep explicit external deliveries, but do not duplicate the Space reply. If messages are forbidden, return an empty summary.`, onResult:"finishWork"});
export default automation({output:z.json(),deployment:{"subscriptions":{"checkSentProductUpdates":{"title":"Sent product update emails","source":{"kind":"recurring","schedule":{"kind":"interval","everySeconds":1800}},"description":"Every 30 minutes, find Customer.io broadcasts whose name starts with the product update prefix that were sent since the last check, and hand each one to the agent to write a blog post.","entry":"checkSentProductUpdates"}},"failurePolicy":"pause"},stages:{"checkSentProductUpdates": code({ input: recurringEventSchema, transitions: ["work"], async run({ctx, input}) { const event = input as RecurringEvent;
const requests: z.infer<typeof requestSchema>[] = [];
const enqueueAgent = (request: z.infer<typeof requestSchema>) => { requests.push(request); return ok(undefined); };
const prepare = async (): Promise<import("neverthrow").Result<z.infer<ReturnType<typeof z.json>>, TypeApiError>> => {
const listed = await ctx.read.request(
{
organizationIntegrationId: customerIo,
method: "GET",
path: "/v1/newsletters",
query: { limit: 50, sort: "desc" },
},
z.unknown(),
);
if (listed.isErr()) return err(listed.error);
const parsed = newsletterList.safeParse(providerBody(listed.value));
if (!parsed.success)
return err(
new TypeApiError(
`Unexpected Customer.io newsletters response: ${parsed.error.message.slice(0, 300)}`,
"invalid_response",
),
);
const sent = parsed.data.newsletters.filter(
(item) =>
productUpdateName.test(item.name) &&
item.send_state === "sent" &&
typeof item.sent_at === "number",
);
const previous = checkpointSchema.safeParse(ctx.checkpoint?.value);
// First run: remember what's already been sent so only future sends get posts.
if (!previous.success) {
const seeded = ctx.saveCheckpoint({
processedIds: sent.map((item) => item.id),
});
if (seeded.isErr()) return err(seeded.error);
return ok({
seeded: true,
alreadySent: sent.map((item) => item.name),
});
}
const processed = new Set(previous.data.processedIds);
const fresh = sent
.filter((item) => !processed.has(item.id))
.sort((a, b) => (a.sent_at ?? 0) - (b.sent_at ?? 0));
if (fresh.length === 0)
return ok({ newProductUpdates: 0, checkedAt: event.scheduledAt });
const handedOff: string[] = [];
for (const item of fresh) {
const contents = await ctx.read.request(
{
organizationIntegrationId: customerIo,
method: "GET",
path: `/v1/newsletters/${item.id}/contents`,
},
z.unknown(),
);
if (contents.isErr()) return err(contents.error);
const body = contentList.safeParse(providerBody(contents.value));
const content = body.success ? body.data.contents[0] : undefined;
const sentAtIso = new Date((item.sent_at ?? 0) * 1000).toISOString();
const staged = await enqueueAgent({
effectKey: `product-update-blog:${item.id}`,
instructions: `A product update email was just sent from Customer.io: "${item.name}" (newsletter ID ${item.id}, sent ${sentAtIso}). Turn it into a detailed, search-optimized ${COMPANY} product update blog post and open a PR assigned to ${PR_ASSIGNEE}, following your automation playbook end to end. The email's text outline is in context. Fetch the full HTML from GET /v1/newsletters/${item.id}/contents before writing.`,
context: {
newsletterId: item.id,
newsletterName: item.name,
sentAt: sentAtIso,
subject: content?.subject ?? null,
preheader: content?.preheader_text ?? null,
emailText: content?.body ? emailText(content.body) : null,
},
});
if (staged.isErr()) return err(staged.error);
processed.add(item.id);
handedOff.push(item.name);
}
const saved = ctx.saveCheckpoint({ processedIds: [...processed] });
if (saved.isErr()) return err(saved.error);
return ok({ newProductUpdates: handedOff.length, handedOff });
};
const prepared = await prepare();
if (prepared.isErr()) return err(prepared.error);
const [current, ...remaining] = requests;
return current ? next("work", { current, remaining, output: prepared.value }) : complete(prepared.value);
} }),
work: workStage,
finishWork: code({input:agentResult(workStage),transitions:["work"],async run({ctx,input}) {
if(input.result.summary) { const delivered = await ctx.write.sendMessage({...{channelId: REPORT_SPACE, ...(REPORT_ROOM.trim() ? { subchannelId: REPORT_ROOM.trim() } : {})}, content:input.result.summary}, {effectKey:"agent-result:"+input.taskInput.current.effectKey}); if(delivered.isErr())return err(delivered.error); }
const [current,...remaining]=input.taskInput.remaining;
return current ? next("work",{current,remaining,output:input.taskInput.output}) : complete(input.taskInput.output);
}})}});
```
Example `blog_conventions` value (illustrative):
```text
a. Add src/content/blog/<slug>.mdx with front matter (title, description, date, category, author, keywords).
b. Add the slug to src/content/blog/index.ts and to the sitemap list.
c. Put images in public/blog/<slug>/ (hero.webp, social.jpg, figure-1.webp …).
d. Run npm run blog:index, npm test -- blog, npm run typecheck and npx prettier --write on changed files.
```
## Verification
**Fixture checks run on this code (local deploy-equivalent validation with and without automation reports enabled, plus stage fixtures; synthetic IDs, no live calls), with optional inputs empty and with all of them set:**
1. First run with no checkpoint → checkpoint seeded with the sent product update IDs; no agent handoff. Passed.
2. Nothing new → `newProductUpdates: 0`; no agent handoff. Passed.
3. One new sent product update → contents fetched, outline built (links kept as "text (url)", `&` decoded), agent handoff with effect key `product-update-blog:19`, checkpoint updated; a draft and a non-matching broadcast are ignored. Passed.
4. `finishWork` with a summary → reaches `ctx.write.sendMessage` to the Space. Type's fixture transport does not accept direct Space-message requests, so this delivery itself is not fixture-tested (the call is unchanged from the source apart from the destination input). Checked.
5. `finishWork` with an empty summary → no message, completes. Passed.
6. `finishWork` with a remaining item → next `work` stage with that item. Passed.
**Proposed acceptance checks after installation:**
1. Enable it once to seed, then send a test broadcast named with the prefix to an internal segment; within 30 minutes, confirm a PR assigned to the reviewer and one report in the Space.
2. Confirm the PR mirrors the newest post's files and passes CI, and that unverifiable claims are listed in its body.
3. Rename a sent broadcast so it does not match the prefix and confirm nothing happens.