
# Link Actions

The `link` family creates a tracked short link while a workflow is running, and hands its address to the next step. A campaign link for a product that did not exist at deploy time cannot be declared in configuration; this is how one gets made. Three operators.

| Operator | Props                                                              | Description                                                              |
| -------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------ |
| `create` | `slug`, `destination`, `title?`, `tags?`, `notes?`, `utm?`         | Mint a link and return its address.                                      |
| `update` | `slug`, plus any of `destination`, `title`, `tags`, `notes`, `utm` | Re-point or re-label an existing link. Omitted props are left unchanged. |
| `delete` | `slug`                                                             | Retire a link. Soft — the click history survives.                        |

```yaml
# Announce a product the moment it is created
- name: mint
  type: link
  operator: create
  props:
    slug: 'product-{{trigger.record.handle}}'
    destination: 'https://example.com/products/{{trigger.record.handle}}'
    title: '{{trigger.record.name}} — launch'
    utm: { source: newsletter, medium: email, campaign: launch }

# Send it
- name: announce
  type: email
  operator: send
  props:
    to: '{{trigger.record.owner_email}}'
    subject: 'Your link is live'
    body: '{{steps.mint.result.shortUrl}}'
```

## The Address a Later Step Receives

`create` and `update` return four fields; `delete` returns `slug` and `changed`.

| Field         | Value                                                                     |
| ------------- | ------------------------------------------------------------------------- |
| `slug`        | The resolved slug, after templates.                                       |
| `destination` | Where the link now points.                                                |
| `shortUrl`    | The absolute address, e.g. `https://app.example.com/l/product-atlas`.     |
| `qrUrl`       | The absolute QR code, e.g. `https://app.example.com/l/product-atlas.svg`. |

:::callout
**Both URLs are absolute, and that is the point.** They are minted to be pasted into an email, an SMS or a PDF, where a root-relative `/l/product-atlas` has no page to resolve against and is simply a dead string. The origin comes from `BASE_URL` when you set it, otherwise from the address the server actually bound to, and only then from `localhost` and the port. Set `BASE_URL` in production — it is what the link a customer clicks will carry.
:::

## Templates Are Resolved First, Then Checked

Every string prop is a template. `slug`, `destination`, `title`, `notes` and each entry of `tags` and `utm` are interpolated against the trigger and earlier steps, and the **resolved** value is what gets validated.

That ordering is what makes the action safe to template freely. A slug is checked against the slug rules after `{{trigger.record.handle}}` has become `atlas`, so a record whose handle is `Not A Slug!` fails the step instead of storing an address nobody can reach. A destination is checked the same way: the resolved value must be a root-relative path or an absolute `http(s)` URL, so a `javascript:` or `data:` value arriving through a record field is refused rather than minted.

## Two Slugs an Automation May Never Take

Both refusals fail the step with the reason named, so a failed run says which of the two happened.

| Refusal                   | Meaning                                                         |
| ------------------------- | --------------------------------------------------------------- |
| `LINK_IS_CONFIG_DECLARED` | The slug is declared in `links[]`. Edit the configuration file. |
| `LINK_SLUG_TAKEN`         | A live link already holds it. Pick another slug.                |

The first is the important one. Configuration is the source of truth for the links it declares, and an automation that could mint over one would be config mutation through a data-shaped side door. `update` and `delete` refuse a config-declared slug for the same reason — they can only ever touch links created at runtime.

`LINK_SLUG_TAKEN` is a refusal, not an overwrite: minting the same slug twice keeps the first link. Re-pointing an address people have already shared, and re-attributing the clicks it has already earned, is not something a repeated trigger should do by accident. Use `update` when re-pointing is what you mean.

Add `continueOnError: true` to the step if a refused mint should not stop the workflow.

## Delete Keeps the History

`delete` is soft. The link stops resolving and answers `404`, and every click it already earned stays in [analytics](/en/docs/analytics), reported under the same slug. A hard delete would destroy the campaign record along with the link.

Deleting twice is a success, not a failure — the second call reports `changed: false`, so a workflow that runs again does not break. A retired slug can be minted again later.

There is no rename. The slug is the address every share already carries and the key every click is recorded under, so changing it would break the shares and orphan the history in one move. Mint a new link instead.

## Related Pages

- [Short Links](/en/docs/short-links) — declaring links in configuration, and how they resolve.
- [Actions Overview](/en/docs/automation-actions-overview) — the action model and the base props every action accepts.
- [Analytics](/en/docs/analytics) — where the clicks these links earn are reported.
- [Email & Notifications](/en/docs/automation-email-actions) — the step that usually sends the minted address.
