#terraform #infrastructure-as-code #buttondown #newsletter #ai

I Used a Coding Agent to Fire a Settings Page

Buttondown has an API and 15 settings menus. I pointed a coding agent at the OpenAPI spec, generated a Terraform provider, and never opened the dashboard again.

I wanted email subscriptions on my blog. Substack would do it, but then Substack owns my subscribers and my archive. I want to own my content and rent the delivery. Buttondown fits: Markdown-native, has an API, no social network ambitions.

I signed up, connected my RSS feed, and opened the settings page.

Fifteen settings sections, each with sub-options. Timezone, archive theme, email templates, confirmation flow, RSS-to-email behavior, custom domain, API keys, metadata. I spent 20 minutes clicking through menus for a tool that bills itself as “the simplest way to send newsletters.”

I changed my description, picked an archive theme, set the timezone. A week later I wanted to revert the description. Buttondown has no version history for settings, no diff view. I was staring at a text field containing whatever I last typed, with no idea what was there before.

Buttondown has an API. A good one. You can manage subscribers, send emails, read analytics. But the API is something I call from code, and the settings page is something I click through in a browser. Two workflows, one audit trail. Guess which one has it.

Config as code, for a newsletter

I wrote a Terraform provider. terraform-provider-buttondown wraps the Buttondown API and exposes newsletter configuration as a Terraform resource. My 20 minutes of menu-clicking became this:

resource "buttondown_newsletter" "edmondo" {
  name          = "edmondo.lol"
  username      = "edmondo"
  description   = "Engineering, incentives, and whatever survives my drafts folder."
  timezone      = "America/New_York"
  archive_theme = "modern"
  template      = "modern"
  locale        = "en"
}

I edit the file, run terraform plan to see what will change, run terraform apply to push it. The old value lives in git. If I break something, git revert fixes it. The Buttondown dashboard stays closed.

The provider also manages welcome emails and templates. I edit HCL instead of hunting through the dashboard for the “confirmation email” settings page I can never find on the first try.

Writing a provider in 2026

Two years ago, building a Terraform provider meant writing hundreds of lines of Go boilerplate per resource. The old SDK (terraform-plugin-sdk) used map[string]*schema.Schema with stringly-typed everything. Each resource required hand-wired schema definitions and type assertions scattered across the CRUD methods. For a provider with 11 resources, that was weeks of work nobody would volunteer for on a side project.

The new framework (terraform-plugin-framework) replaced all of that with Go structs and struct tags:

type NewsletterResourceModel struct {
    ID           types.String `tfsdk:"id"`
    Name         types.String `tfsdk:"name"`
    Username     types.String `tfsdk:"username"`
    Description  types.String `tfsdk:"description"`
    Timezone     types.String `tfsdk:"timezone"`
    ArchiveTheme types.String `tfsdk:"archive_theme"`
    Template     types.String `tfsdk:"template"`
    Locale       types.String `tfsdk:"locale"`
}

The CRUD methods map to Buttondown’s API. Create calls POST /v1/newsletters. Update compares the plan against the current state field by field and sends a PATCH with only what changed:

func (r *NewsletterResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
    var plan, state NewsletterResourceModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)

    input := client.NewsletterUpdateInput{}
    if !plan.Description.Equal(state.Description) {
        v := plan.Description.ValueString()
        input.Description = &v
    }
    // ... same pattern for each field

    var n client.Newsletter
    r.client.Patch(ctx, "/v1/newsletters/"+state.ID.ValueString(), input, &n)
    resp.Diagnostics.Append(resp.State.Set(ctx, newsletterToModel(&n))...)
}

The framework handles plan/state diffing, import support, and diagnostics. I spent more time reading the Buttondown API docs than writing provider code. One quirk: Buttondown has no GET /v1/newsletters/:id endpoint, so the Read method lists all newsletters and filters by ID. Not elegant, but it works for a resource you’ll have one of.

11 resources, one OpenAPI spec, and a coding agent

The provider covers more than newsletter settings. Buttondown publishes their OpenAPI spec publicly. I pulled it in as a git submodule, pointed a coding agent at the spec and the terraform-plugin-framework docs, and asked it to generate resources.

The result is 11 resource types:

ResourceWhat it manages
buttondown_newsletterName, description, timezone, theme, template, locale
buttondown_automationTriggered email sequences
buttondown_emailDraft and sent emails
buttondown_external_feedRSS feeds that Buttondown monitors
buttondown_webhookEvent callbacks to external URLs
buttondown_formSubscription forms
buttondown_surveyReader surveys
buttondown_snippetReusable email content blocks
buttondown_tagSubscriber tags
buttondown_bookDownloadable content
buttondown_userSubscriber management

Each resource follows the same pattern: a Go struct for the model, four CRUD methods that call the Buttondown API, and a mapping function between the API response and the Terraform state. The pattern is repetitive enough that the agent generated most of these in one session. I reviewed the output, fixed a few edge cases (Buttondown’s API is inconsistent about which fields are nullable), and had a working provider.

Building a Terraform provider used to be a commitment. For a side project, the boilerplate cost killed most ideas before they started. Now I hand an agent the OpenAPI spec and the SDK docs. It produces working resource code. I decide what to expose, review what it wrote, and handle the API quirks the spec doesn’t capture. The balance shifted from “can I afford to build this” to “do I want this enough to review the output.”

I would not have built this provider two years ago. Not for a personal newsletter.

The settings page problem

Buttondown isn’t doing anything wrong. The API works. The product works. Settings pages are the default interface for SaaS configuration, and they have no memory. You save, the old value is gone. You change three things at once, and a week later you can’t tell which one broke your email formatting.

SaaS startups sell “look how simple this is” to the person who signs the check. And it is simple, for the first setup. Click through the wizard, pick your options, done. But the moment you need to track what changed, roll back a bad configuration, or hand the account to someone else, “simple” turns into “opaque.” Change management is not on the roadmap because it doesn’t help close the next deal. It helps the customer who already paid, and that customer has less leverage.

This is fine for toy setups. For anything you depend on, configuration without version control is a liability someone decided not to prioritize.

Terraform solves this by accident. The config is a file in git, so you get version history. terraform plan shows the diff before applying, so you get review. git revert exists, so you get rollback. None of this required Buttondown to build anything new. Their API was enough.

The provider is open source and works as a Pulumi provider through the Terraform bridge. I tried to publish it on the Terraform registry, but the registry has a bug in its publishing flow. I wrote to support. Nobody answered. Maybe I need a Terraform provider for the Terraform registry.

Enjoyed this post?

← Back to all posts