> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fuseai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Personalize every email with AI research

> Have an AI smart column research each prospect and write a tailored opening line, check the sources behind it, then merge the line into your campaign emails.

Generic first lines get ignored. This workflow has a [smart column](/guides/smart-columns) research each prospect and write one specific opening line, lets you check the sources behind it, and merges it into a campaign with a column token. Each email uses the line as it stands when that email is scheduled, so the workflow finishes the research and deals with any gaps before anything is approved.

```mermaid theme={null}
%%{init: {"flowchart": {"rankSpacing": 20, "nodeSpacing": 20}}}%%
flowchart TD
  A[Contact list] -->|listId| B[Opener smart column]
  B -->|every row answered| C[Campaign step]
  C -->|column token| D[Personalized sends]
```

## Before you start

| You need | Why |
| - | - |
| An API key with the `lists` and `campaigns` [scopes](/concepts/scopes) | Smart columns sit on `lists`, and the campaign on `campaigns`. |
| A contact list with work emails | Build one with [Build an AI outbound campaign from a prompt](/workflows/ai-campaign-from-a-prompt) or [Qualify leads with AI](/workflows/qualify-leads-before-enrichment). |
| A connected mailbox | The campaign cannot be approved without one. |
| Credits | The smart column's `creditsPerRow` for every row it writes: 20 with `standard`, 100 with `deep_research`. |

## Step by step

<Steps>
  <Step title="Write the opener prompt and test it on ten rows">
    Ask for exactly the sentence you want to send, and tell the column what to do when it finds nothing, so every row ends up with a usable line:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68a1f20b9c41d20014b3e901/smart-columns \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Opener",
        "prompt": "Write one sentence, under 25 words, that opens a cold email to this person by referring to something specific their company announced, launched or published in the last six months. No greeting, no mention of our product, no invented facts. If you find nothing specific, write one sentence about a challenge common to their role instead.",
        "engine": "deep_research",
        "runOn": "first_10"
      }'
    ```

    ```json theme={null}
    {
      "jobId": "68b4a1109c41d20014b43055",
      "column": { "id": "68b4a1109c41d20014b43050", "name": "Opener", "engine": "deep_research" }
    }
    ```

    `deep_research` checks live sources for every row and records them, which matters when the line will be sent under your name. For large lists where a lighter touch is fine, `standard` is cheaper.
  </Step>

  <Step title="Check the lines and the sources behind them">
    Poll `GET /business/lists/{listId}/smart-columns/{jobId}/status` until the run finishes, then read the lines under `columnValues` on `GET /business/lists/{listId}/rows`. For any line you want to check, ask where it came from:

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/lists/68a1f20b9c41d20014b3e901/rows/68a1f2c89c41d20014b3e955/columns/68b4a1109c41d20014b43050/provenance \
      -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    {
      "provenance": {
        "reasoning": "Brightpay announced payroll support for Ireland and Portugal on its blog in August.",
        "sourceUrls": ["https://brightpay.com/blog/new-countries"],
        "fetchedAt": "2026-10-02T09:15:00.000Z"
      }
    }
    ```

    If the lines are too long, too salesy or wrong, change the prompt now with `PATCH /business/lists/{listId}/smart-columns/{columnId}`. A prompt edit rewrites every line already written, so it is cheapest while that is only ten rows.
  </Step>

  <Step title="Write lines for the rest of the list">
    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/lists/68a1f20b9c41d20014b3e901/smart-columns/68b4a1109c41d20014b43050/rerun \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "mode": "new_only" }'
    ```

    `new_only` writes lines only for rows that do not have one yet, so the ten sample rows are not billed twice. You can build the campaign in the next steps while this runs. Just do not approve it until the run has finished.
  </Step>

  <Step title="Create a manual campaign">
    Column tokens go in the steps you write yourself, so use a manual campaign. Its steps exist as soon as it is created:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/campaigns \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "campaignName": "Fintech finance leaders - researched openers",
        "campaignType": "manual",
        "listId": "68a1f20b9c41d20014b3e901",
        "channels": ["email", "email"],
        "timezone": "Europe/London"
      }'
    ```

    Then confirm the exact token for the column:

    ```bash theme={null}
    curl https://api.tryfuse.ai/api/v1/business/campaigns/68b4a2009c41d20014b43101/variables -u "$FUSE_API_KEY:"
    ```

    ```json theme={null}
    { "variables": [{ "token": "{Column: Opener}", "name": "Opener", "listName": "Fintech finance leaders" }] }
    ```
  </Step>

  <Step title="Put the opener in the first email">
    List the steps with `GET /business/campaigns/{campaignId}/steps`, then write the first one:

    ```bash theme={null}
    curl -X PATCH https://api.tryfuse.ai/api/v1/business/campaigns/68b4a2009c41d20014b43101/steps/68b4a2019c41d20014b43111 \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "Quick question, {Contact First Name}",
        "body": "Hi {Contact First Name},\n\n{Column: Opener}\n\nWe help finance teams at companies like {Contact Company} close the books in three days instead of ten. Worth a 20-minute call next week?\n\n{Sender First Name}",
        "daysBetween": 0
      }'
    ```

    Write the follow-up step the same way, with `daysBetween` set to the wait in days. Built-in tokens such as `{Contact First Name}` use single braces and exact names; see [Merge tokens](/guides/campaigns#merge-tokens).
  </Step>

  <Step title="Wait for every line, then close the gaps">
    Poll the re-run's status until it is `completed` or `partially_completed`. Then page through `GET /business/lists/{listId}/rows` and look for rows where `columnValues` has no value for the column. Those are the rows the column could not answer.

    A contact with an empty cell would get the email with a gap where the opener goes. Take them off the list before you approve:

    ```bash theme={null}
    curl -X DELETE https://api.tryfuse.ai/api/v1/business/lists/68a1f20b9c41d20014b3e901/members \
      -u "$FUSE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{ "contactIds": ["68a1f2c89c41d20014b3e9c2"] }'
    ```

    This only removes them from the list, not from your workspace. Put them in a list of their own if you want to write their openers by hand.
  </Step>

  <Step title="Preview, then approve">
    Render the step for a sample contact with `POST /business/campaigns/{campaignId}/steps/{stepId}/generate` and read it as the prospect will see it. Then approve:

    ```bash theme={null}
    curl -X POST https://api.tryfuse.ai/api/v1/business/campaigns/68b4a2009c41d20014b43101/approve \
      -u "$FUSE_API_KEY:"
    ```
  </Step>
</Steps>

## How column values reach the email

* **The value is read when each email is scheduled.** It is then frozen onto the queued email, so changing the column afterwards does not rewrite emails already queued. Finish editing the prompt before you approve.
* **An empty cell leaves a gap.** A row the column failed on, or found nothing for, renders as nothing, and the email still sends. That is why step 6 removes those contacts first.
* **A line still being written stops the contact.** If a contact's cell is still running when their step comes due, that step is not scheduled and is not retried automatically. Always let the run finish before the campaign reaches a contact.

## Add new prospects in batches

Columns belong to a list, and a dynamic campaign enrolls new rows within about 30 minutes, often before a smart column could write their line. So add new prospects in batches rather than straight to the campaign's list:

1. Put the batch in a new list and add a smart column named `Opener` with the same prompt.
2. Let it finish, and remove any rows it could not answer.
3. Add the batch's list to the campaign with `PATCH /business/campaigns/{campaignId}` and the full `listIds`, old lists included. With `isDynamic: true` set on the campaign, at creation or in the same `PATCH`, it enrolls the batch on its next check.

The token resolves by column name across the campaign's lists, so `{Column: Opener}` finds each contact's line on whichever list holds it.

## What it costs

| Step | Credits |
| - | - |
| Writing openers | The column's `creditsPerRow` for every row it answers, charged as each row is answered. The balance must cover every row when a run starts, and failed rows are free. `GET /business/lists/{listId}/smart-columns/{columnId}` shows the rate. |
| Editing the prompt | Rewrites every line already written, at the same rate |
| Reading rows, provenance and campaign variables | Free |

## Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| An email went out with a gap where the opener goes | The contact's cell was empty: the column failed or found nothing for them. | Before approving, remove contacts whose cell is empty (step 6). |
| Some contacts never get an email | Their cell was still being written when their step came due, so the contact stopped. | Let runs finish before approving, and add new prospects in batches. Put stopped contacts in a new list and campaign. |
| `{Column: Opener}` reaches a prospect as typed | The token does not match a column on the campaign's source lists. | Copy the token from `GET /business/campaigns/{campaignId}/variables`. |
| `variables` is empty | The smart column is on a different list from the campaign's source list. | Add the column to the list the campaign sends from. |
| An edited line was not used | The email was already queued, and queued emails keep the line they were rendered with. | Edit the prompt before approving. |
| `422 COLUMN_NAME_RESERVED` | The name matches a built-in column such as `name`, `email` or `company`. | Choose another name. |

## Run the whole workflow

<Accordion title="Python script: researched openers into a manual campaign">
  ```python theme={null}
  """Write a researched opening line for every prospect and merge it into a manual campaign."""
  import os
  import time

  import requests

  BASE = "https://api.tryfuse.ai/api/v1/business"
  AUTH = (os.environ["FUSE_API_KEY"], "")
  LIST_ID = os.environ["FUSE_LIST_ID"]
  FINISHED_RUNS = ("completed", "partially_completed", "failed", "cancelled")


  class FuseError(Exception):
      def __init__(self, status, code, message):
          super().__init__(f"{status} {code}: {message}")
          self.code = code


  def call(method, path, **kwargs):
      """Send one request, wait out 429s, and raise FuseError on any other failure."""
      while True:
          response = requests.request(method, BASE + path, auth=AUTH, timeout=60, **kwargs)
          if response.status_code == 429:
              time.sleep(int(response.headers.get("Retry-After", 5)))
              continue
          if response.status_code >= 400:
              try:
                  error = response.json().get("error", {})
              except ValueError:
                  error = {}
              raise FuseError(response.status_code, error.get("code"), error.get("message"))
          return response.json() if response.content else None


  def wait_for_run(job_id, delay=10, max_delay=60):
      while True:
          time.sleep(delay)
          job = call("GET", f"/lists/{LIST_ID}/smart-columns/{job_id}/status")["job"]
          if job["status"] in FINISHED_RUNS:
              return job
          delay = min(delay * 1.5, max_delay)


  def all_rows():
      page = 1
      while True:
          body = call("GET", f"/lists/{LIST_ID}/rows", params={"limit": 1000, "pageNum": page})
          yield from body["data"]
          if page >= body["pagination"]["totalPages"]:
              return
          page += 1


  # 1. Test the opener prompt on ten rows
  created = call("POST", f"/lists/{LIST_ID}/smart-columns", json={
      "name": "Opener",
      "prompt": "Write one sentence, under 25 words, that opens a cold email to this person by referring to "
                "something specific their company announced, launched or published in the last six months. "
                "No greeting, no mention of our product, no invented facts. If you find nothing specific, "
                "write one sentence about a challenge common to their role instead.",
      "engine": "deep_research",
      "runOn": "first_10",
  })
  column_id = created["column"]["id"]
  wait_for_run(created["jobId"])

  # 2. Show each sample line with its sources
  for row in call("GET", f"/lists/{LIST_ID}/rows", params={"limit": 10})["data"]:
      line = row["columnValues"].get(column_id)
      if line:
          sources = call("GET", f"/lists/{LIST_ID}/rows/{row['id']}/columns/{column_id}/provenance")
          print(f"{row['firstName']} at {row['companyName']}: {line}\n  {sources['provenance']['sourceUrls']}")
  if input("Use these lines? [y/N] ").strip().lower() != "y":
      raise SystemExit(f"Edit the prompt with PATCH /lists/{LIST_ID}/smart-columns/{column_id}, then continue.")

  # 3. Write lines for the rest of the list
  rerun = call("POST", f"/lists/{LIST_ID}/smart-columns/{column_id}/rerun", json={"mode": "new_only"})

  # 4. Create a manual campaign and find the column's token while the run works
  campaign_id = call("POST", "/campaigns", json={
      "campaignName": "Fintech finance leaders - researched openers",
      "campaignType": "manual",
      "listId": LIST_ID,
      "channels": ["email", "email"],
      "timezone": "Europe/London",
  })["campaignId"]
  token = next(v["token"] for v in call("GET", f"/campaigns/{campaign_id}/variables")["variables"]
               if v["name"] == "Opener")

  # 5. Write both steps around the researched line
  first, follow_up = sorted(call("GET", f"/campaigns/{campaign_id}/steps")["steps"],
                            key=lambda step: step["sequenceNumber"])
  call("PATCH", f"/campaigns/{campaign_id}/steps/{first['id']}", json={
      "subject": "Quick question, {Contact First Name}",
      "body": "Hi {Contact First Name},\n\n" + token + "\n\nWe help finance teams at companies like "
              "{Contact Company} close the books in three days instead of ten. "
              "Worth a 20-minute call next week?\n\n{Sender First Name}",
      "daysBetween": 0,
  })
  call("PATCH", f"/campaigns/{campaign_id}/steps/{follow_up['id']}", json={
      "subject": "Re: Quick question, {Contact First Name}",
      "body": "Hi {Contact First Name}, worth a look? Happy to send a two-minute video instead of a call.\n\n"
              "{Sender First Name}",
      "daysBetween": 3,
  })

  # 6. Wait for every line, then take contacts without one off the list
  if wait_for_run(rerun["jobId"])["status"] not in ("completed", "partially_completed"):
      raise SystemExit("The run did not finish; check its status before approving.")
  gaps = [row["id"] for row in all_rows() if not row["columnValues"].get(column_id)]
  if gaps:
      print(f"{len(gaps)} contacts have no opener and will be removed from the list")
      for start in range(0, len(gaps), 5000):
          call("DELETE", f"/lists/{LIST_ID}/members", json={"contactIds": gaps[start:start + 5000]})

  # 7. Preview, then approve
  print(call("POST", f"/campaigns/{campaign_id}/steps/{first['id']}/generate")["preview"]["body"])
  if input("Approve and start sending? [y/N] ").strip().lower() == "y":
      call("POST", f"/campaigns/{campaign_id}/approve")
  ```
</Accordion>

## Do it from Claude

With the [Fuse MCP server](/mcp/connect) connected:

> On my list "Fintech finance leaders", add a smart column called "Opener" that writes one sentence under 25 words referring to something specific each person's company announced in the last six months. Run it on ten rows and show me the lines with their sources.

> Run "Opener" on the rest of the list and wait for it to finish. Remove anyone it could not write a line for, then draft a two-step manual campaign on the list that opens the first email with that column. Don't launch it.

The assistant uses the smart column and list tools, then creates the campaign as a draft. Removing rows is marked as destructive, so your client may ask you to approve that step.

## Related

<CardGroup cols={2}>
  <Card title="Smart columns" icon="sparkles" href="/guides/smart-columns">
    Engines, prompts, re-runs and provenance.
  </Card>

  <Card title="Campaigns" icon="paper-plane" href="/guides/campaigns#merge-tokens">
    Merge tokens and how column values render.
  </Card>

  <Card title="Qualify leads before enriching" icon="filter" href="/workflows/qualify-leads-before-enrichment">
    Build the list this workflow personalizes.
  </Card>

  <Card title="Follow up on replies and clicks" icon="reply" href="/workflows/follow-up-on-engagement">
    Hand off repliers and re-engage clickers after the campaign.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.