← Back

Building a Campaign Operations Agent

Jul 5, 2026 (1d ago)

Every campaign starts the same way. Someone needs an email built.

What used to follow was a relay race. A request gets dropped in Slack. We open Linear and file a ticket. Our team opens Knak, builds the email, and works through drafts with stakeholders and approvers there. Then we export the HTML and rebuild it in Customer.io, our sending platform, by hand.

None of that is the work. The work is the campaign. The rest is logistics.

So we taught mOperator, our Slack-native marketing ops agent, to run the logistics. Now the whole thing happens in one thread.

mOperator is more than this one pipeline. Most of what it does day to day is the operational grind around campaigns: importing and cleaning lead lists, creating Salesforce campaigns, and standing up landing pages in Contentful or event pages in Luma. It also files enriched Linear issues, summarizes GitHub changes, and answers Salesforce questions in plain language. Every write action runs behind an approval gate, so a human still says yes. We shared a light version of that general-purpose agent publicly on GitHub, without the campaign build pipeline described here. Campaign builds are one more of its jobs, and this post is about that one.

We built this over a two-day offsite in NYC, three of us on the MOPs team wiring the pieces together. On the walk back to the hotel we passed one of our own Ship billboards, which turned out to summarize the week pretty well.

One of Vercel's Ship billboards in Manhattan at sunset: Build agents. Secure agents. Ship agents.

One of our Ship billboards in Manhattan. Build agents, secure agents, ship agents. We were mostly on the third one.

The relay race, before

Our MOPs team is three people, including Ethan, who owns campaign ops and email. We build every campaign email in Knak, work through drafts with stakeholders, and run each build through a dual-custody QA pass across the team before anything goes out. That process is solid. The problem was never the QA. It was everything around it.

The old path touched four tools and at least two people:

  1. Slack the request, usually half-structured, sometimes just a paragraph and a Figma link.
  2. Linear a ticket, filed before any building starts, so the work is tracked from the first minute.
  3. Knak our email builder. We translate the request into a built email, working through drafts with stakeholders and approvers.
  4. Customer.io the sending platform. We rebuild the Knak email as something Customer.io can actually send.

Every arrow between those tools is a copy, a paste, and a chance for something to slip. The building and the QA are skilled work our team is genuinely good at. The shuttling of data from one system to the next is not the skilled part, and doing it by hand is slow and easy to get wrong.

That is exactly the kind of work to hand to a machine.

The pipeline, after

Here is the whole flow now:

Slack form, deterministic parse, auto-build in Knak, sync to Customer.io as native components, link back in the thread.

Slack form Parse Knak build Extract Customer.io Review & publish

The build pipeline. mOperator runs the middle four stages end to end; the dashed steps stay human. A teammate submits the form, and a person reviews and publishes. In between: a deterministic parse that preserves bold and links, a Knak build on a lean theme, an HTML-to-manifest extraction, and a re-render into Customer.io's native components.

A teammate fills out a Slack workflow form. mOperator reads it, builds the email in Knak, syncs it into Customer.io, and posts "Open in Knak" and "Open in Customer.io" buttons back to the same thread. The requester kicks it off from Slack, and the built draft is waiting in Knak for them to review and edit. Nobody on our team hand-builds it anymore.

The first time it ran end to end, a campaign request became a built, on-brand email in Knak and a draft in Customer.io with nobody assembling it by hand. A person still reviews it, edits in Knak if something is off, and approves. Building the email is just no longer the job.

Let me walk each stage.

The front door: a Slack form

The entry point is a Slack workflow, not a chat prompt. That is deliberate.

You can let people describe an email in free text and have an LLM sort it out. But campaign requests have a shape: audience, subject, preheader, body, CTA, brand, send date. A form captures that shape cleanly, and a form means the parse can be deterministic. No guessing, no hallucinated fields, no "did it understand me?"

The form also draws a line we always wanted and never had a good place to enforce. It expects approved copy.

In the old shape, a request often reached us before the copy was final. We would build the email, then edit through several rounds as it moved through the review chain. Our team absorbed a lot of that editing. It worked, but it put the drafting cycle downstream of the build, which is the wrong order. The new workflow moves that work upstream: drafting and stakeholder sign-off happen before the form gets filed. Marketers get a clear place to self-serve the copy, and the build starts from something final. mOperator reads the structured submission and turns it into a build request. The one place judgment is needed, cleaning up body copy while preserving bold and links, is handled in our own parser before anything is sent downstream, so what reaches the builder is predictable.

Rules for structure. AI only where judgment is actually required. That split shows up everywhere in this system.

Auto-building in Knak

Knak is where the email gets built. It also has an API, which is the part that made this possible.

There is one constraint worth knowing: Knak's API will not let you inject exact HTML. The only way to populate a real email body through the API is its AI generate endpoint. So that is what we drive. We hand it our cleaned content against a lean, on-brand theme, and it renders a built email.

A few things we learned the hard way:

  • Output is theme-driven, not prompt-driven. Telling the generator "don't add a testimonial section" does not reliably prune it. Structure comes from the theme, not from pleading in the prompt. The fix was a lean theme, not a cleverer prompt.
  • Naming has to be automatic. We generate names to a strict convention (region, type, brand, title, date, Linear ID) because Knak cannot rename an asset after creation. Get it right the first time or live with it.
  • Preserve the formatting yourself. Bold, links, and structure have to survive our parser, because the builder renders exactly what it is given.

When it is done, mOperator drops an "Open in Knak" button in the thread. The requester sees a real, built email in the tool of record, seconds after submitting a form. From there they review it in Knak and edit directly if something needs to change. Nobody starts from a blank canvas, and nobody hand-builds the email.

Syncing into Customer.io as native components

This is the stage we are proudest of, and it is the one that reuses work from the last thing we shipped.

When we migrated our email library to Customer.io, the hard-won lesson was that pushing raw HTML is the wrong target. HTML imports into Customer.io's Design Studio as one opaque block. Operators cannot edit it in the GUI without touching markup. What you actually want is native components, headings, body copy, buttons, images, dividers, that show up as real, editable elements.

So for the migration, we built a renderer that emits Customer.io's own component markup: an <x-base> / <x-section> shell wrapping native components, pushed through the Design Studio API.

That renderer is now the sync engine for live campaign builds.

When a Knak email is built, mOperator takes the exported HTML, runs it through an extractor that parses it into a typed manifest of blocks (headline, body, list, image, button) and re-renders each block as the matching Customer.io component. Then it pushes the result to the Design Studio API. The email lands in Customer.io as editable native components, subject and preheader set, UTMs re-attributed to the new channel, ready for a human to review and publish.

The extractor's output is a typed list of blocks, so every stage downstream works against a known shape instead of raw HTML:

type Block =
  | { kind: "headline"; level: 1 | 2 | 3; text: string }
  | { kind: "body"; html: string }              // bold and links preserved
  | { kind: "list"; ordered: boolean; items: string[] }
  | { kind: "image"; alt: string; light: string; dark: string }
  | { kind: "button"; text: string; href: string }

Each block re-renders into its matching component, wrapped in the section shell that carries the light and dark backgrounds:

<x-base>
  <x-section width="640px" :outer-background="{ light: `#FFFFFF`, dark: `#0A0A0A` }">
    <x-heading>Ship 2026 is coming to New York</x-heading>
    <x-text>Doors open <strong>June 30</strong>. Space is limited.</x-text>
    <x-image :src="{ light: `hero-light.png`, dark: `hero-dark.png` }" alt="Ship 2026" />
    <x-button href="https://vercel.com/ship?utm_source=customerio">Save your seat</x-button>
  </x-section>
</x-base>

The details are fiddly in the way integrations always are:

  • HTML entities double-encode if you are not careful. A & in a heading became a literal &amp; on screen because it got escaped twice, once by us, once on ingestion. Body copy was fine; only attribute-bound text broke. The kind of bug you only find by looking at a real sent email.
  • Light/dark images bind through a specific path. The image component would not take a plain URL the obvious way. Getting a hero to render in both light and dark mode meant matching the exact binding the platform expects. Two modes, two source slots, one very specific syntax.

None of that is glamorous. All of it is the difference between "it imported" and "it actually works."

One recent addition, from last week: the sync can now push the raw HTML straight across instead of re-rendering into components. Components are still the safer target for anything that needs editing in Customer.io's GUI, but since we live in Knak and review there, we often prefer working out of the HTML. So the sync does both.

What's automated, what stays human

The throughline in everything we build: rules for speed, AI for judgment, humans for authority. This pipeline draws the line on purpose.

Automated:

  • Parsing the request
  • Building the email in Knak
  • Naming to convention (Linear ID read from the thread)
  • Rendering to Customer.io components
  • Setting subject, preheader, from/reply-to
  • Rewriting UTMs
  • Posting links back to the thread

Deliberately manual:

  • Review and any edits in Knak, by the requester, right after the build
  • Visual review of the built email, run as a dual-custody QA pass across our MOPs team
  • The final publish

A machine can assemble the email. It should not be the one who decides it is good enough to send. Two sets of eyes still sign off, and the last click stays with a person, on purpose.

The bigger picture

The point was never to build emails faster, though it does that. It was to remove the operational work around them.

The point is that our campaign stack, Slack, Knak, Linear, Customer.io, Salesforce, used to be a set of destinations a human walked between, carrying data by hand. Now it is an assembly line that runs from a single Slack thread. The tools did not change. What changed is that they are scriptable now, and mOperator is the thing doing the scripting.

The migration was the boring part, moving old emails to a new platform. This is the interesting part: the same building blocks, turned around to power how the team ships new work.

Fewer arrows between tools. Fewer chances to get it wrong. More time on the campaign, less on the logistics.