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

# Building Outreach & Adding Images

> How an AI client builds broadcasts, series and inbox replies over MCP: the markdown content model, the image upload tools, and the end-to-end build flow.

## What this covers

With the `automations:write` and `inbox:write` scopes, a connected AI client (Claude Desktop, Cursor, Claude Code, a custom agent) can do everything the Outreach builder and the inbox composer can, including dropping screenshots and product images into the emails it writes. This page explains the three things a client needs to get right:

1. **Content is markdown-first.** Write bodies in markdown; Halo renders the HTML with the same converter the dashboard editor uses, so a human can keep editing what the AI drafted.
2. **Images go through `media_upload_image`.** Upload once, get a public URL, reference it with `![alt](url)`.
3. **Launching is a handshake.** Readiness, test send, audience preview, then activate with the previewed count.

> [!NOTE]
>
> All of this is exposed as ordinary MCP tools. If you connect Claude and simply say "build me a 4-step onboarding series for new Pro users, with a screenshot of the integrations page in email 2", the tools below are what Claude calls under the hood. You do not need to call them by hand.

## The content model

Every email, in-app message and banner step stores two fields:

| Field | What it is |
| - | - |
| `body_markdown` (stored as `body_text`) | The editable source. This is exactly what the dashboard composer keeps behind the scenes. |
| `body_html` | The rendered email body that is actually sent. Derived from the markdown by the server. |

Always write `body_markdown`. The server derives `body_html` with the composer's own converter, so the step opens in the builder fully editable: images render as image blocks, buttons as button blocks, merge tags as chips. If a client writes only HTML the step still sends, but the builder shows it as plain text and loses the rich blocks on the next human edit.

Supported markdown:

| Syntax | Renders as |
| - | - |
| Blank line between paragraphs | Separate paragraphs (email-safe spacing is added at send time) |
| `# Heading`, `## Heading` | H1 / H2 (deeper levels are capped at H2) |
| `**bold**`, `*italic*`, `~~strike~~`, `` `code` `` | Inline formatting |
| `- item` / `1. item` | Bullet / numbered lists |
| `[text](https://…)` | Link that opens in a new tab (`[text\|same](url)` for same tab) |
| `[button:Label](https://…)` | A call-to-action button. Add `\|center` or `\|#ff6600` segments for alignment / colour |
| `![alt](https://…)` on its own line | An image block (must be an https URL, see below) |
| `[video](https://youtube.com/…)` | A YouTube / Vimeo / Loom embed |
| `---` | A divider |
| `{{first_name\|there}}`, `{{company.name}}` | Merge tags with an optional fallback |

The same converter is used by `inbox_reply_ticket_email` (`body_markdown`) and `inbox_message_customer` (`content_format: "markdown"`). Chat replies render markdown natively in the widget, including images.

## Personalisation (merge tags)

Subjects and bodies can carry merge tags that are filled in per recipient at send time. The client does not need to guess which ones exist: `automation_list_merge_fields` returns the full catalogue for the workspace, including custom traits.

| Group | Examples | Source |
| - | - | - |
| Contact | `{{first_name\|there}}`, `{{last_name}}`, `{{name\|there}}`, `{{email}}`, `{{company_name\|your team}}`, `{{phone}}`, `{{mrr}}`, `{{renewal_date}}`, `{{renewal_status}}` | Columns on the contact. `first_name` is derived from the full name when no explicit first name exists. |
| Company | `{{company.name}}`, `{{company.plan}}`, `{{company.industry}}`, `{{company.renewal_date}}`, `{{company.signed_up_at}}` | Columns on the contact's company. |
| Custom traits | `{{department\|your team}}`, `{{role}}`, `{{onboarding_step}}` | Whatever your product sends via `identify()` (see [User Traits](https://docs.haloagents.ai/contacts/user-traits.md)). The tool lists each key with how many contacts have it populated. |

Syntax is `{{field}}` or `{{field|fallback}}`; the fallback is used when the recipient has no value. Always give `first_name` and `name` a fallback such as `|there`.

Two safety nets catch mistakes before anything sends:

* `automation_upsert_step` returns a `merge_tags` summary for the step, with a warning for every tag that will not resolve (a typo like `{{frist_name}}`, a composer-only field such as `{{plan}}` that lives on the company, a Stripe tag, or a greeting with no fallback).
* `automation_readiness` repeats those warnings across every email step of the outreach.

Merge tags are **not** substituted in inbox replies. `inbox_reply_ticket_email`, `inbox_reply_ticket_chat` and `inbox_message_customer` refuse bodies that contain outreach-style tags so a customer never receives literal braces; the client should write the customer's real name from `data_get_ticket` instead.

## Adding images

Email bodies cannot carry inline `data:` images (mail clients and Halo's sanitizer both refuse them), so an image has to live at a public URL first. Two tools handle that:

### `media_upload_image`

Pass **either** `data_base64` (the raw PNG / JPEG / GIF / WebP bytes) **or** `source_url` (a public https URL Halo downloads for you). Optional `alt` and `file_name`.

```json theme={null}
{
  "name": "media_upload_image",
  "arguments": {
    "source_url": "https://your-cdn.example.com/integrations-page.png",
    "alt": "The Integrations page with HubSpot connected",
    "file_name": "integrations-page"
  }
}
```

Returns:

```json theme={null}
{
  "url": "https://…/help-center-assets/<org>/email/1760-integrations-page-k2x9ab.png",
  "content_type": "image/png",
  "bytes": 184233,
  "width": 1600,
  "height": 900,
  "markdown": "![The Integrations page with HubSpot connected](https://…png)",
  "html": "<img src=\"https://…png\" alt=\"The Integrations page with HubSpot connected\" style=\"max-width: 100%;\" />"
}
```

Paste `markdown` on its own line in the body. Images land in the same storage folder the dashboard composer uses, so they also appear when a teammate opens the step.

| Limit | Value |
| - | - |
| Formats | PNG, JPEG, GIF, WebP (verified by magic bytes; SVG is refused) |
| `source_url` size | 10 MB |
| `data_base64` size | Keep under \~3 MB. The JSON-RPC request body is capped by the hosting platform, so larger files should be uploaded by URL |
| Fetch safety | `source_url` goes through Halo's SSRF-safe fetcher: https only, no private / loopback / metadata addresses, bounded redirects |

### `media_list_images`

Lists the workspace's previously uploaded email images, newest first, each with its URL and a ready-made markdown snippet, plus the org's email-branding logo URL. Use it to reuse the logo or last week's screenshot instead of uploading again.

### Permissions

Both tools are available with either `automations:write` or `inbox:write`, and the person who authorized the connection must hold `automation:edit` or `inbox:reply` in the dashboard, the same gate as the composer's upload button.

## Building a broadcast

**1. Create**

`automation_create` with `type: "broadcast"`. The broadcast is seeded with one empty email step; `automation_get` returns its `step_id`.

**2. Pick the sender**

`automation_list_senders` lists verified from-addresses, teammates, aliases and inbox views. Pass one of `from_address_id` / `from_member_id` / `from_alias_id` to `automation_update`, along with `email_style` (`plain`, `personal`, `branded`) and `reply_to`.

**3. Write the email**

`automation_upsert_step` with the `step_id`, a `subject` and `body_markdown`. Upload any images first and paste their markdown.

**4. Target**

`automation_set_audience` with `all`, a saved `segment_id` (from `data_list_segments`), ad-hoc `filters`, or a `subscription_id`.

**5. Check, test, preview**

`automation_readiness` returns blockers and non-blocking warnings (no sender chosen, past schedule, non-https images). `automation_send_test` emails you a `[TEST]` copy (defaults to the person who authorized the connection). `automation_preview_audience` returns the live recipient count.

**6. Activate**

`automation_activate` with `expected_recipient_count` set to the previewed `users_count`. The server re-resolves the audience and refuses on any mismatch.

## Building a series

The same flow, with two differences: the series starts with no steps, and the entry rule matters.

**1. Create and target**

`automation_create` with `type: "series"`, then `automation_set_audience`.

**2. Set the flow**

`automation_set_flow` with the `entry_rule` (`manual`, `user_created`, `event` + `event_name`, or `segment_match`), optional `exit_rule` and `goal`.

**3. Add steps in order**

Call `automation_upsert_step` once per step: an `email` (subject + `body_markdown`), then a `wait` (`delay_days: 3`), then the next email, and so on. Steps append in call order; pass `step_order` to insert in the middle and later steps shift down. `condition` steps take `settings: { condition_type, condition_value }`.

**4. Review**

`automation_get` returns every step in flow order with its markdown and the images it references. Edit any step by passing its `step_id` back to `automation_upsert_step`; only the fields you pass change.

**5. Launch**

Readiness, test send, preview, activate, exactly as for a broadcast. Series with a `user_created` or `event` trigger enrol contacts as they qualify and skip the recipient-count handshake.

The series canvas is maintained for you: steps created over MCP are positioned and wired from the trigger in order, inserting in the middle rewires the chain, and deleting a step bridges the wire across it. If a teammate has already arranged the canvas by hand, their wiring is kept and new steps attach after their predecessor.

Prefer starting from something that already worked? `automation_duplicate` clones an existing broadcast or series as a new draft, including its steps, audience, settings and canvas.

## Replying in the inbox with images

`inbox_reply_ticket_email` takes `body_markdown`; `inbox_reply_ticket_chat` renders `content` as markdown in the widget. In both cases upload the screenshot with `media_upload_image` and paste the returned markdown:

```json theme={null}
{
  "name": "inbox_reply_ticket_email",
  "arguments": {
    "ticket_id": "…",
    "body_markdown": "Here is where the toggle lives:\n\n![Settings → Notifications](https://…/notifications.png)\n\nLet me know if that does it."
  }
}
```

`inbox_message_customer` (proactive outreach on a new thread) accepts `content_format: "markdown"` and renders the email channel the same way.

## Tool reference

| Tool | Scope | What it does |
| - | - | - |
| `automation_get` | `automations:write` (grantor needs `automation:view`) | Read the draft: metadata, audience, sender, style, schedule, flow, and every step with `body_markdown` + image URLs |
| `automation_list_senders` | `automations:write` (grantor needs `automation:view`) | Verified from-addresses, teammates, aliases, reply-to inboxes, activation prerequisites |
| `automation_list_merge_fields` | `automations:write` (grantor needs `automation:view`) | Every merge tag that resolves at send time: contact, company and this workspace's custom traits |
| `automation_duplicate` | `automations:write` | Clone an outreach as a new draft |
| `automation_update` | `automations:write` | Name, schedule, sender, `email_style`, `reply_to`, `use_full_name_in_from` |
| `automation_upsert_step` | `automations:write` | Create / edit a step from `body_markdown` (or `body_html`), with `delay_days` / `delay_hours` for waits and ordered inserts |
| `automation_readiness` | `automations:write` | Blockers + warnings + per-step summary |
| `automation_send_test` | `automations:write` | `[TEST]` copy to teammates (defaults to you) |
| `media_upload_image` | `automations:write` or `inbox:write` | Store an image, get URL + markdown / html snippets + dimensions |
| `media_list_images` | `automations:write` or `inbox:write` | Reuse previously uploaded images and the branding logo |
| `inbox_reply_ticket_email` | `inbox:write` | Email reply from `body_markdown` |
| `inbox_message_customer` | `inbox:write` | Proactive message; `content_format: "markdown"` |

Every call, allowed or refused, lands in the `action_executions` audit log. Image bytes are redacted from the audit row.

## Where to go next

> **[Scopes & Permissions](https://docs.haloagents.ai/mcp/scopes-and-permissions.md)**
>
> The full scope grammar and the grantor-permission gate behind every write.

> **[Series](https://docs.haloagents.ai/outreach/series.md)**
>
> What waits, conditions, triggers and A/B variants do once the AI has drafted the steps.

> **[Broadcasts](https://docs.haloagents.ai/outreach/broadcasts.md)**
>
> Sender options, email styles, scheduling and results.

> **[Sending emails](https://docs.haloagents.ai/channels/sending-emails.md)**
>
> Verified domains, from-addresses and how plain, personal and branded emails are wrapped at send time.


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