---
title: Use extensions to surface app actions
description: Use extensions to surface app actions
source_url:
  html: 'https://shopify.dev/docs/apps/build/sidekick/build-app-actions'
  md: 'https://shopify.dev/docs/apps/build/sidekick/build-app-actions.md'
---

# Use extensions to surface app actions

By declaring and defining your app's actions in an app extension, Sidekick can take the merchant to the right page in your app to perform a rich action.

***

## Example: Edit email content

In this example, a merchant can ask Sidekick to edit the content of an email campaign. Sidekick takes the merchant to the [Shopify Messaging](https://apps.shopify.com/shopify-email) app, navigated to the correct email campaign.

![Sidekick with email app](https://shopify.dev/assets/assets/admin/sidekick/sidekick-messaging-BmyWcPHr.png)

***

## Expose actions to Sidekick

Use app extensions to expose actions in your app. By providing your app's actions in an app extension, Sidekick can take the merchant to the right page in your app to perform a rich action.

Multiple app extension types are supported. Choose the right app extension type for your app.

| Extension type | Target | App Home support | Standalone app support |
| - | - | - | - |
| Admin link | `admin.app.intent.link` | ✅ | ❌ |
| UI extension | `admin.app.intent.render` | ✅ | ✅ |

Both targets declare the same intents and share the same tools, instructions, and schema files. The difference is how the action is performed when Sidekick invokes the intent:

* **`admin.app.intent.link`**: Navigates the merchant to a URL in your app. Use this when the action is best performed by the merchant on an existing page you already render.
* **`admin.app.intent.render`**: Renders an [admin UI extension](https://shopify.dev/docs/api/admin-extensions) inline. Use this when you want Sidekick to hand control to a focused, purpose-built extension surface instead of navigating away. Requires API version `2026-04` or later.

***

## Requirements

* [Create an app](https://shopify.dev/docs/apps/build/scaffold-app).
* For App Home, use the latest version of [App Bridge](https://shopify.dev/docs/api/app-home).
* Use [token exchange](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens#token-exchange-grant) to get access tokens and [Shopify managed installation](https://shopify.dev/docs/apps/build/authentication-authorization/cli-app-authentication) for installs and scope grants, not the legacy OAuth install flow. See [Authentication](https://shopify.dev/docs/apps/build/sidekick/build-app-data#authentication).
* Add an [`extensions_summary`](https://shopify.dev/docs/apps/build/sidekick#add-an-extensions-summary-to-your-app) to your `shopify.app.toml`. This is required for all apps with Sidekick-eligible extensions.

**Limits:**

You can register a maximum of 5 intents and 20 tools for each app. The tool limit is shared across all extension types (data and action).

***

## Example: Allow Sidekick to edit email content with an admin link

Use an app extension to allow Sidekick to edit content in your app. When the intent fires, Sidekick navigates the merchant to the URL you define.

### Create an app extension

Use the [Shopify CLI](https://shopify.dev/docs/apps/build/cli-for-apps) to create a Sidekick app action link extension. (For the UI-extension variant, `admin.app.intent.render`, see [Example: Allow Sidekick to edit email content with a UI extension](#example-allow-sidekick-to-edit-email-content-with-a-ui-extension).)

## App action link

```terminal
shopify app generate extension --template app_action_link --name open-email
```

The command creates a new extension template in your app's `extensions` directory with the following structure:

## Edit extension folder structure

```toml
extensions/open-email
  ├── email-schema.json // Schema definition for the intent's input parameters
  ├── instructions.md // Guidelines for Sidekick on tool usage
  ├── README.md
  ├── shopify.extension.toml // The config file for the extension
  └── tools.json // Tool definitions for Sidekick actions
```

Modify `shopify.extension.toml` to include the `name`, `handle`, and `type` of your app extension.

## extensions/open-email/shopify.extension.toml

```toml
[[extensions]]
name = "Open email"
description = "Edit an email campaign"
handle = "open-email"
type = "admin_link"


[[extensions.targeting]]
target = "admin.app.intent.link"
url = "/edit/{id}"
tools = "./tools.json"
instructions = "./instructions.md"
```

**Note:**

`admin.app.intent.link` is a special target that is not tied to a Shopify resource that can be invoked from anywhere in the Shopify Admin.

The `description` field helps Sidekick understand when your extension is relevant. Be specific about what your extension does. A vague description means Sidekick won't reliably invoke your extension when merchants need it. See [Writing effective extension descriptions](https://shopify.dev/docs/apps/build/sidekick#writing-effective-extension-descriptions) for detailed guidance and examples.

The `url` may contain `{placeholder}` segments that are substituted at invocation time from values in your intent schema. Placeholders aren't filled automatically. You must declare a field with `mapTo: "param"` in the schema whose name (or `fieldName` alias) matches the placeholder. See [Map intent values into the URL](#map-intent-values-into-the-url) for the full mechanism.

### Register your extension as an intent

Add an `intents` configuration to your `shopify.extension.toml` with an `action`, `type` and `schema`.

`application/email` is used as an example `type` for this guide because this guide walks through editing an email campaign. App extensions support several app types for various popular use cases.

**Pick the type that matches your use case:**

The `type` shown below (`application/email`) is illustrative. Don't copy it verbatim unless your extension is actually for email campaigns. Pick the type from the supported types table below that best describes what your extension does. A mismatched type means Sidekick won't reliably invoke your extension when merchants need it.

If none of the supported types fit your scenario, then discuss or propose a new intent type in the [Shopify app intent types repository](https://github.com/Shopify/app-intent-types). Don't pick an unrelated type as a placeholder.

## extensions/open-email/shopify.extension.toml

```toml
[[extensions.targeting.intents]]
type = "application/email"
action = "edit"
schema = "./email-schema.json"
```

The following types are currently supported for app intents. Supported actions vary by intent family.

| Type | Description | Supported actions | Schema reference |
| - | - | - | - |
| `application/ad` | Ad campaigns | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/ad.json` |
| `application/campaign` | Marketing campaigns | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/campaign.json` |
| `application/email` | Email campaigns | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/email.json` |
| `application/faq` | FAQ management | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/faq.json` |
| `application/loyalty-program` | Loyalty programs | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/loyalty-program.json` |
| `application/optimization-plan` | Optimization plans | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/optimization-plan.json` |
| `application/quote` | Quotes for B2B quote and negotiation apps | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/quote.json` |
| `application/return` | Returns management | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/return.json` |
| `application/review` | Product reviews | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/review.json` |
| `application/shipment` | Shipment tracking | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/shipment.json` |
| `application/ticket` | Support tickets | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/ticket.json` |
| `shopify/Customer` | Shopify customers | `import`, `import+bulk` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/customer/gid.json` |
| `shopify/Discount` | Shopify discounts | `create`, `edit` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount.json` |
| `shopify/Order` | Shopify orders | `import`, `import+bulk` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/order/gid.json` |
| `shopify/Product` | Shopify products | `import`, `import+bulk` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/product/gid.json` |

`shopify/*` app intents aren't the same as Shopify admin route intents or native admin intents. `shopify/Customer`, `shopify/Order`, and `shopify/Product` are for app-provided import workflows and they support `import` and `import+bulk`. `shopify/Discount` supports `create` and `edit` for discounts your app backs with a Shopify Function. For schema examples, see [Import Shopify resources with `shopify/*` intents](#import-shopify-resources-with-shopify--intents).

If you'd like Shopify to support another type, then discuss or propose it in the [Shopify app intent types repository](https://github.com/Shopify/app-intent-types).

**Unsupported types fail at deploy:**

Declaring an unsupported `type` (for example, `application/my-resource`) causes `shopify app deploy` to reject the extension with `Intent is invalid: type '<value>' is not supported`. There's no silent fallback.

### Declare the extension schema

Declare your schema in the `JSON` file referenced in `shopify.extension.toml`. The full intent schema definition can be inspected at <https://extensions.shopifycdn.com/shopifycloud/schemas/v1/intent.json>.

**Note:**

`inputSchema` is required and must reference a schema matching the `type` defined in `shopify.extension.toml`. For example, `application/email` should reference <https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/email.json>

The intent `inputSchema` must not declare `required` fields (at the top level or nested inside any property). Intents render UI for merchants to fill in missing information, so all fields are treated as optional at invocation time. Use `description` and other constraints (such as `minLength`, `format`, `enum`) to guide input instead.

The schema has two top-level sections that an intent invocation populates:

* `value`: the primary identifier for the resource being acted on (for example, the GID of the email campaign being edited). This becomes the `value` parameter of `intents.invoke({ value })`.
* `inputSchema`: additional data the caller provides (for example, the recipient or subject). Properties under `inputSchema.properties` become the `data` object of `intents.invoke({ data })`.

The `value` field, and each property in `inputSchema.properties`, can declare `mapTo` and `fieldName` to control how the value is transported when Sidekick opens your `url`, and `matchValue` to participate in disambiguation when multiple intents share the same `type:action` pair. See [Map intent values into the URL](#map-intent-values-into-the-url) for the URL transport mechanism, and [`matchValue`](#matchvalue) for the disambiguation mechanism.

## ./email-schema.json

```json
{
  "$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/intent.json",
  "value": {
    "type": "string",
    "description": "The GID of the email campaign to edit.",
    "mapTo": "param",
    "fieldName": "id"
  },
  "inputSchema": {
    "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/email.json",
    "type": "object",
    "properties": {
      "recipient": {
        "type": "string",
        "description": "Primary recipient email address (e.g., email@example.com)",
        "format": "email"
      },
      "cc": {
        "type": "array",
        "description": "CC recipient email addresses",
        "items": {"type": "string", "format": "email"}
      },
      "subject": {
        "type": "string",
        "description": "Email subject line (1-200 characters)",
        "minLength": 1,
        "maxLength": 200
      },
      "body": {
        "type": "string",
        "description": "Email message body (supports rich text formatting)"
      },
      "template_id": {
        "type": "string",
        "description": "Email template to use",
        "enum": ["blank", "welcome", "promotion"]
      },
      "send_at": {
        "type": "string",
        "description": "Schedule email for future delivery (ISO 8601 date-time format)",
        "format": "date-time"
      },
      "priority": {
        "type": "string",
        "description": "Email priority level",
        "enum": ["low", "normal", "high"],
        "default": "normal"
      },
      "track_opens": {
        "type": "boolean",
        "description": "Enable email open tracking",
        "default": true
      }
    }
  }
}
```

**Common mistakes to avoid:**

#### Leaving the URL placeholder unmapped

If your `url` contains a placeholder such as `{id}`, then something in the schema must map to it. The mapping must be either the top-level `value` field with `mapTo: "param"` and a matching `fieldName`, or an `inputSchema` property with `mapTo: "param"` (whose key matches the placeholder, or which declares a matching `fieldName`). Otherwise you'll get a runtime error such as "Missing ':id' param", which means the URL placeholder is never substituted. See [Map intent values into the URL](#map-intent-values-into-the-url) for the valid patterns.

#### Removing `$ref` or its sibling keywords from `inputSchema`

The `inputSchema` block uses [JSON Schema 2020-12](https://json-schema.org/draft/2020-12/schema), which allows `$ref` alongside `type` and `properties`. This is intentional. Removing `$ref`, or removing the `type`/`properties` siblings alongside it, breaks schema validation and produces a deploy-time error: `Intent is invalid: a $ref for the inputSchema matching the intent type must be present`.

#### Adding `required` to the intent `inputSchema`

Don't add a `required` array anywhere in the intent `inputSchema`, not at the top level and not nested inside any property (for example, on array items). This produces a deploy-time error (`Intent is invalid: inputSchema must not have required fields`). The `required`-forbidden rule applies only to intent schemas. Tool `inputSchema` allows `required` like normal JSON Schema.

### Write your tools schema

**Tools here run after the page opens, not before:**

The tools declared in this `tools.json` aren't navigation tools. They execute while the intent's destination page is open.

The tools declared in this `tools.json` execute *while the intent's destination page is open* at the route specified by the `url` for the `admin.app.intent.link` target. Navigation is already owned by `admin.app.intent.link` itself, so don't declare a tool that just duplicates the intent's action (for example, an `edit_email` tool on an `application/email` `edit` intent that only opens the editor).

Tools should expose the ability to read or mutate state *while the merchant is on the page*. Examples: `design_email`, `apply_template`, `schedule_send`. Each tool must be registered at runtime via [`shopify.tools.register`](#bind-tool-handlers-at-runtime) from the route the intent's `url` opens, otherwise Sidekick has no handler to invoke.

Declare your tools in a `JSON` file referenced by the `tools` field in `shopify.extension.toml`. Tools define the actions Sidekick can perform while the intent is open. These should be specific to the intent context. For example, a `design_email` tool updates the visual design of the email campaign the merchant is currently editing.

The tool `inputSchema` is a standard [JSON Schema](https://json-schema.org/). Unlike the intent `inputSchema`, it doesn't require a `$ref` to an `application/*` schema, and it can use `required` to mark mandatory fields just like any normal JSON Schema (see the [data extension example](https://shopify.dev/docs/apps/build/sidekick/build-app-data) for a tool that does this). Keep the two schemas distinct and don't conflate them.

## ./tools.json

```json
[
  {
    "$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/tool.json",
    "name": "design_email",
    "description": "Update the visual design of the currently open email campaign, including background color, layout, and styling",
    "inputSchema": {
      "type": "object",
      "properties": {
        "background_color": {
          "type": "string",
          "description": "Background color for the email body (hex code, e.g. #FFFFFF)"
        },
        "layout": {
          "type": "string",
          "description": "Email layout template",
          "enum": ["single-column", "two-column", "hero-image"]
        },
        "font_family": {
          "type": "string",
          "description": "Primary font family for email text"
        }
      }
    }
  }
]
```

**Limits:**

Each tool `name` can be up to 64 characters. Each tool `description` can be up to 512 characters. You can register a maximum of 20 tools for each app, shared across all extensions (data and action).

***

## Map intent values into the URL

When Sidekick invokes an intent, your app receives the request at the `url` declared in `shopify.extension.toml`. The intent schema controls where each value ends up: a URL path segment, the query string, the URL hash, or the request body.

### How placeholder substitution works

A `url` can contain `{placeholder}` segments (for example, `url = "/app/customers/{id}"`). Placeholders are not filled in automatically. For each placeholder, the intent schema must declare a matching field with `mapTo: "param"`. The match is by name:

* For an `inputSchema` property, the property's key is used by default. Declare `fieldName` only when the key doesn't match the placeholder (see [`fieldName`](#fieldname) below).
* For the top-level `value` field, you must always declare `fieldName: "<placeholder>"`, because `value` has no schema key to use as a default.

If nothing in the schema maps to a placeholder, the literal `{id}` (or an empty segment) is sent to your app.

#### Working with GIDs

When the value mapped to a `param` placeholder is a [GID](https://shopify.dev/docs/api/usage/gids) (a string starting with `gid://`), Sidekick takes everything after the last `/` and substitutes that bare tail identifier into the URL path. Both shapes that show up across these docs work the same way:

* `gid://shopify/EmailCampaign/123` becomes `123` in `/app/campaigns/123/edit`.
* `gid://application/email/123` also becomes `123`.

Your route reads that bare identifier from path params (`useParams()` or your router's equivalent), not the full GID. The same parsing applies to any `inputSchema` property mapped to `param`. You don't need a resolver helper.

Non-GID strings are substituted as-is. Values mapped to `query_param`, `hash`, or form data destinations also pass through verbatim, without GID parsing. If you need the full GID at runtime, read it from the [intent payload](#read-the-incoming-intent) instead of the URL.

### `mapTo`

`mapTo` specifies how a value is transported when Sidekick constructs the request to your `url`. It's a string with one of the following values:

| `mapTo` | Effect | Example |
| - | - | - |
| `form_data` | Sends the value in the request body as form data. Rarely needed. This is the default for `inputSchema` properties when no `mapTo` is declared. | N/A |
| `hash` | Appends the value to the URL's hash fragment. | `/app/campaigns#edit` |
| `param` | Substitutes the value into a `{placeholder}` in the URL path. | `/app/campaigns/{id}` → `/app/campaigns/4353532` |
| `query_param` | Appends the value to the URL's query string. | `/app/campaigns?status=draft` |

`mapTo` is valid on the top-level `value` field and on any property inside `inputSchema.properties`.

### `fieldName`

`fieldName` is the target name at the destination specified by `mapTo`:

* `mapTo: "param"`: the placeholder name in the URL path (the text between `{` and `}`).
* `mapTo: "query_param"`: the key used in the query string.
* `mapTo: "form_data"`: the form field name.

If `fieldName` is omitted, the property key from the schema is used. Declare `fieldName` when your schema key doesn't match the destination name. For example, when an `inputSchema` property is called `reviewId` but needs to fill an `{id}` path placeholder:

```json
"reviewId": {
  "type": "string",
  "mapTo": "param",
  "fieldName": "id"
}
```

The top-level `value` field always needs an explicit `fieldName` when mapped to `param` or `query_param`, because `value` isn't a property key that can be used as a default.

### `matchValue`

`matchValue` specifies a value this field must equal for the intent to match during disambiguation. It's used when multiple intents share the same `type:action` pair. Shopify narrows to the intent whose `matchValue` equals the value provided in `intents.invoke({ data, value })`.

`matchValue` is a string and is valid on the top-level `value` field and on any property inside `inputSchema.properties`.

When an intent is invoked without a corresponding `data` or `value` field, Shopify still shows the intent as a candidate in the disambiguation modal, so users can pick between intents that declare a `matchValue`. When invoked with a `data` or `value` field, only intents whose `matchValue` equals the provided value are kept as candidates.

For example, an app that exposes two campaign-edit intents, one for SMS and one for email, can disambiguate by `channel`:

```json
// extensions/edit-sms-campaign/schema.json
"inputSchema": {
  "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/campaign.json",
  "type": "object",
  "properties": {
    "channel": {
      "type": "string",
      "matchValue": "sms"
    }
  }
}
```

```json
// extensions/edit-email-campaign/schema.json
"inputSchema": {
  "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/campaign.json",
  "type": "object",
  "properties": {
    "channel": {
      "type": "string",
      "matchValue": "email"
    }
  }
}
```

Invoking `intents.invoke("edit:application/campaign", { data: { channel: "sms" } })` then routes directly to the SMS extension without prompting the user.

### Example: campaign edit with `{id}` substitution

This example registers an extension for editing a marketing campaign. The URL contains an `{id}` placeholder that's filled from the intent's `value`.

## extensions/open-campaign/shopify.extension.toml

```toml
[[extensions]]
name = "Open campaign"
description = "Edit a marketing campaign"
handle = "open-campaign"
type = "admin_link"


[[extensions.targeting]]
target = "admin.app.intent.link"
url = "/app/campaigns/{id}/edit"
tools = "./tools.json"
instructions = "./instructions.md"


[[extensions.targeting.intents]]
type = "application/campaign"
action = "edit"
schema = "./campaign-schema.json"
```

## ./campaign-schema.json

```json
{
  "$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/intent.json",
  "value": {
    "type": "string",
    "description": "The GID of the campaign to edit.",
    "mapTo": "param",
    "fieldName": "id"
  },
  "inputSchema": {
    "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/campaign.json",
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "description": "The display name of the campaign."
      },
      "budget_amount": {
        "type": "number",
        "description": "The total budget for the campaign in the shop's currency.",
        "minimum": 0
      }
    }
  }
}
```

When Sidekick invokes this intent with a campaign GID, the `value` is substituted into the `{id}` placeholder before the request reaches your server. Any `inputSchema` properties (like `name` or `budget_amount`) are sent as form data by default.

### Example: rename a field with `fieldName`

If an `inputSchema` property's key doesn't match the URL placeholder, use `fieldName` to map them. The following schema routes the `reviewId` property into the `{id}` path segment:

## ./review-schema.json

```json
{
  "$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/intent.json",
  "inputSchema": {
    "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/review.json",
    "type": "object",
    "properties": {
      "reviewId": {
        "type": "string",
        "description": "The GID of the review.",
        "mapTo": "param",
        "fieldName": "id"
      }
    }
  }
}
```

With `url = "/app/reviews/{id}/edit"`, invocations that supply `reviewId` render as `/app/reviews/<reviewId>/edit`.

***

## Bind tool handlers at runtime

After declaring tools statically in `tools.json`, use the [Tools API](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/tools-api) to bind handler functions at runtime.

The schema in `tools.json` only declares what the tool looks like to Sidekick. The handler is the code that actually runs when Sidekick invokes the tool.

**Where the handler runs:**

For an `admin_link` app extension, the handler is registered from the React component for the route that the extension's `url` navigates to. Sidekick opens that page, the page mounts, and the page registers the handler. This mirrors the [data extension](https://shopify.dev/docs/apps/build/sidekick/build-app-data) flow, where the handler is registered from the JavaScript module declared by `module = "./src/index.js"`.

Use `useEffect` so the handler is bound when the page mounts and unregistered when the merchant navigates away. `shopify.tools.register` returns a cleanup function that handles the unregistration:

```jsx
import {useEffect} from 'react';
import {useParams} from 'react-router';


export default function CampaignEditor() {
  // `id` is the URL path parameter that Sidekick substituted from the
  // intent's `value` via `mapTo: "param"`. It's already the bare
  // identifier, not the full `gid://shopify/EmailCampaign/123` GID.
  const {id: campaignId} = useParams();


  useEffect(() => {
    const cleanup = shopify.tools.register('design_email', async (input) => {
      const response = await fetch(`/api/campaigns/${campaignId}/design`, {
        method: 'PATCH',
        body: JSON.stringify(input),
      });
      return response.json();
    });


    return () => cleanup();
  }, [campaignId]);


  // …render the campaign editor UI
}
```

The handler closure captures the route's state (here, `campaignId`), so re-running the effect when that state changes replaces the handler with one bound to the new campaign. The cleanup function unregisters it when the component unmounts.

See the [Tools API reference](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/tools-api) for the full surface, including `unregister`, `clear`, and additional lifecycle examples.

***

## Read the incoming intent

When your extension opens for an app intent, read the incoming payload from [`shopify.intents.request`](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/intents-api#request). Read the current value synchronously from `shopify.intents.request.value` (it's `null` when your app isn't running inside an intent workflow). The payload contains the `action`, `type`, `value`, and `data` fields for the invocation.

```js
const request = shopify.intents.request?.value;
if (request) {
  // request.action is the operation, for example 'create' or 'edit'
  // request.type matches the type declared in shopify.extension.toml
  // request.data contains the fields defined by your intent's input schema
  hydrateForm(request);
}
```

If your extension stays mounted across intent transitions, use `shopify.intents.request.subscribe(callback)` to react to updates without polling.

The same payload is also appended to the landing URL as an `?intent=<URL-encoded JSON>` query parameter on the initial page load. Use it only when server-side rendering, where App Bridge isn't available yet: read it from the incoming request URL in, for example, a Remix loader or Next.js `getServerSideProps`. Everywhere else, read `shopify.intents.request` and subscribe to updates, because the query parameter is only present on that first load and goes stale if the host updates the current intent without reloading the iframe. See the [`request`](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/intents-api#request) section of the Intents API reference for the full payload shape.

***

## Resolve the intent

When a merchant finishes, fails, or cancels the workflow your extension renders for an app intent, resolve the intent using the [Intents API](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/intents-api#response-methods) `response` methods. This returns control to the surface that invoked your intent and delivers the result.

```js
// On successful completion, pass any data the invoker needs:
await shopify.intents.response.ok({campaignId: 'gid://shopify/EmailCampaign/123'});


// On failure, return an error message (and optional validation issues):
await shopify.intents.response.error('Could not save the email campaign.');


// If the merchant cancels without completing:
await shopify.intents.response.closed();
```

See the [Intents API reference](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/intents-api#response-methods) for the full `response` surface.

***

## Write instructions for using the app extension

Define instructions for how Sidekick should use your app extension in an `instructions.md` file.

**Note:**

`instructions.md` is optional, but is highly recommended for providing context and guidance to Sidekick about your app extension.

## ./instructions.md

```md
## When to Use the Design Email Tool


Use the `design_email` tool when the merchant asks to:
- Change the background color or layout of the email they're editing
- Update the visual styling or font of the current campaign
- Switch to a different email layout template


## Important Guidelines


- Only use `design_email` while the merchant has an email campaign open
- When updating the design, confirm changes with the merchant before applying
- Refer to layouts as "single-column", "two-column", or "hero-image"
```

**Limits:**

The `description` field has a 256-token limit. The `instructions.md` file has a 2,048-token limit.

***

## Navigation constraints

An intent is bound to exactly one pathname: the `url` declared on your extension's `admin.app.intent.link` target. The intent modal represents work happening at that pathname, and the platform enforces this by closing the modal whenever the merchant navigates away from it.

**Intent modals close when the pathname changes:**

Any navigation that changes the pathname inside an intent modal closes it. This applies to every navigation mechanism that produces a different pathname, including:

* Server-side redirects (for example, an HTTP `302` from a loader or action that points at a different pathname).
* Client-side navigation via `useNavigate()`, `<Link>`, `<Form>`, or equivalent router APIs that target a different pathname.
* Setting `window.location` to a different pathname.

The modal closes silently. No error is surfaced. If your intent disappears immediately after loading, a pathname change is the most likely cause.

Updating the search string or hash on the same pathname (for example, `/app/customers?id=123`) does **not** close the modal. Use that to switch UI state without leaving the intent.

### Render different UI states at the same pathname

Because an intent is pinned to a single pathname, switch between UI states using query parameters, loader data, or component state instead of pathname changes. For example, render a list view and a detail form at the same route, driven by a `?id=123` query string or inline component state:

## app/routes/app.customers.jsx

```jsx
export async function loader({ request }) {
  const url = new URL(request.url);
  const id = url.searchParams.get('id');
  return {
    id,
    customer: id ? await fetchCustomer(id) : null,
  };
}


export default function Customers() {
  const { id, customer } = useLoaderData();
  // Render the detail view inline when `id` is set, otherwise the list.
  return id ? <CustomerForm customer={customer} /> : <CustomerList />;
}
```

When the merchant finishes their work, call [`shopify.intents.response.ok(data)`](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/intents-api#response-methods) to resolve the intent. The invoking surface closes the modal, so you don't need to navigate yourself.

***

## End-to-end example: search, open, and edit an email campaign

This walkthrough connects the two halves of the Sidekick app extensions surface: the [data extension](https://shopify.dev/docs/apps/build/sidekick/build-app-data) that returns resource links, and the action-link extension on this page that opens the editor. It uses the same `application/email` example shown earlier on this page so the moving parts line up with what you've already seen.

The trip has five turns:

1. The merchant asks Sidekick a question. Sidekick decides that an email-related question matches the app's `extensions_summary` and invokes the data extension's `search_campaigns` tool.
2. `search_campaigns` returns resource links with `mimeType: "application/email"`. Sidekick presents the results to the merchant.
3. Because the link's `mimeType` (`application/email`) matches an `edit` intent on the action-link extension, Sidekick offers to edit one of the campaigns and asks the merchant which. After the merchant replies (for example, "edit the first campaign"), Sidekick invokes the intent with that campaign's resource-link `uri` as the `value` (for example, `gid://application/email/123`).
4. Shopify opens the intent's `url` (`/edit/{id}`) in the intent modal. Because the schema declares `mapTo: "param"`, Sidekick strips the GID and substitutes the bare tail, so the route receives `/edit/123`. The page mounts and calls [`shopify.tools.register("design_email", …)`](#bind-tool-handlers-at-runtime).
5. The merchant asks Sidekick to change something about the design. Sidekick invokes `design_email` with the proposed update. The handler stages the change into form state and returns. The merchant clicks **Save** themselves; Sidekick never commits silently.

### 1.​The data extension returns resource links

`search_campaigns` lives in the data extension you built in [the data extension guide](https://shopify.dev/docs/apps/build/sidekick/build-app-data). Its handler returns resource links whose `mimeType` matches the intent type declared in the next step:

## extensions/tools/src/index.js

```js
export default () => {
  shopify.tools.register('search_campaigns', async ({query, status}) => {
    const response = await fetch('/api/campaigns/search', {
      method: 'POST',
      body: JSON.stringify({query, status}),
    });
    const campaigns = await response.json();


    return {
      results: campaigns.map((campaign) => ({
        type: 'resource_link',
        // `campaign.id` is a local identifier like `123`, not a GID. Compose
        // the URI here so it stays a well-formed `gid://` value.
        uri: `gid://application/email/${campaign.id}`,
        name: campaign.subject,
        mimeType: 'application/email',
        _meta: {
          status: campaign.status,
          editedAt: campaign.editedAt,
          openRate: campaign.openRate,
        },
      })),
    };
  });
};
```

The `mimeType` (`application/email`) is what makes each result clickable. Sidekick matches it to the action-link extension's intent `type` in the next step. The `_meta` field carries the small amount of summary data the model needs to reason about the result without a separate fetch.

### 2.​The action-link extension declares the intent and the in-page tool

The `extensions/open-email` extension you scaffolded earlier on this page already has the right shape. Its `shopify.extension.toml` declares the landing URL and intent:

## extensions/open-email/shopify.extension.toml

```toml
[[extensions]]
name = "Open email"
description = "Edit an email campaign"
handle = "open-email"
type = "admin_link"


[[extensions.targeting]]
target = "admin.app.intent.link"
url = "/edit/{id}"
tools = "./tools.json"
instructions = "./instructions.md"


[[extensions.targeting.intents]]
type = "application/email"
action = "edit"
schema = "./email-schema.json"
```

The full schema was shown earlier in [Declare the extension schema](#declare-the-extension-schema), with all the optional `inputSchema.properties` for an email campaign. The piece that matters for this walkthrough is the top-level `value` field, which maps the GID Sidekick passes into the `{id}` placeholder via `mapTo: "param"` and `fieldName: "id"`.

The extension's `tools.json` declares `design_email`, the in-page tool that runs *after* the page is open. It doesn't redeclare navigation; that's already handled by `admin.app.intent.link` and the `url` above. The full tool definition is in [Write your tools schema](#write-your-tools-schema) earlier on this page.

### 3.​The app route registers the in-page tool

When the merchant clicks a search result, Shopify opens `/edit/<id>`. Because `mapTo: "param"` strips the GID before substitution, the route receives the bare tail identifier (for example, `123`), not the full GID. No URL parsing helper is needed; see [Working with GIDs](#working-with-gids) for the contract.

## app/routes/edit.$id.tsx

```tsx
import {useEffect, useState} from 'react';
import {useParams, useLoaderData} from 'react-router';
import type {LoaderFunctionArgs} from 'react-router';
import {authenticate} from '../shopify.server';
import {getCampaign} from '../models/campaign.server';


export async function loader({params, request}: LoaderFunctionArgs) {
  const {cors} = await authenticate.admin(request);
  const campaign = await getCampaign(params.id!);
  return cors(Response.json({campaign}));
}


export default function EditCampaign() {
  // `id` is the bare campaign identifier (for example, `123`) that
  // Sidekick extracted from the intent's `value` GID via mapTo: "param".
  const {id} = useParams();
  const {campaign} = useLoaderData<typeof loader>();
  const [staged, setStaged] = useState(campaign);


  useEffect(() => {
    const cleanup = shopify.tools.register('design_email', async (input) => {
      setStaged((prev) => ({...prev, ...input}));
      return {
        ok: true,
        campaign_id: id,
        staged: input,
        note: 'Changes staged in the form. Awaiting merchant Save.',
      };
    });
    return () => cleanup();
  }, [id]);


  // ...render the staged campaign and a Save button that commits to /api/campaigns/:id
}
```

The tool's response includes `ok: true` and a `note` that tells Sidekick the change is staged, not saved. Sidekick relays that to the merchant so they know to click **Save** themselves.

### Verify the round trip locally

Run this end-to-end check before you ship. Each step builds on the previous one. If a step fails, fix it before moving on.

1. `shopify app dev` starts and prints both extensions as ready.
2. Open the Sidekick preview link printed by `dev` for the `admin.app.tools.data` target. Ask Sidekick a question that should hit `search_campaigns` (for example, "show me my best-performing campaigns from last month").
3. Confirm Sidekick presents the results from your tool (typically as a text summary, not raw JSON). That confirms the tool returned valid resource links.
4. Ask Sidekick to edit one (for example, "edit the first one"). The intent modal opens at `/edit/<id>` with the bare ID substituted into the URL. If you see a literal `{id}` or an empty path segment, your schema's `mapTo` / `fieldName` is misconfigured. See [Map intent values into the URL](#map-intent-values-into-the-url).
5. With the editor open, ask Sidekick to change something about the design (for example, "switch this to the two-column layout"). Sidekick invokes `design_email`; the change appears staged in the form.
6. Click **Save** in the form. The change is committed by your app, not by Sidekick.

***

## Putting it all together

After following this tutorial, your `extensions` folder structure should look like this:

## Folder structure

```toml
extensions/open-email
  ├── email-schema.json // Schema definition for the intent's input parameters
  ├── instructions.md // Guidelines for Sidekick on tool usage
  ├── README.md
  ├── shopify.extension.toml // The config file for the extension
  └── tools.json // Tool definitions for Sidekick actions
```

***

## Example: Allow Sidekick to edit email content with a UI extension

When you want Sidekick to open a dedicated UI instead of navigating to a URL, create an [admin UI extension](https://shopify.dev/docs/api/admin-extensions) with the `admin.app.intent.render` target. The intent declaration, tools, instructions, and schema are identical to the admin link path.

**Requirements:**

The `admin.app.intent.render` target requires API version `2026-04` or later, and Shopify CLI `3.94.0` or later for the `app_action` template.

### Create an app extension

Use the [Shopify CLI](https://shopify.dev/docs/apps/build/cli-for-apps) to create a Sidekick app action UI extension.

## App action UI extension

```terminal
shopify app generate extension --template app_action --name open-email
```

The command creates a new extension template in your app's `extensions` directory with the following structure:

## Edit extension folder structure

```toml
extensions/open-email
  ├── src
  │   └── IntentExtension.jsx // The UI extension code Sidekick mounts when the intent fires
  ├── intent-schema.json // Schema definition for the intent's input parameters
  ├── instructions.md // Guidelines for Sidekick on tool usage
  ├── README.md
  ├── shopify.extension.toml // The config file for the extension
  └── tools.json // Tool definitions for Sidekick actions
```

### Configure the UI extension target

Use `type = "ui_extension"` and point the `target` at `admin.app.intent.render`. Unlike the admin link, this target renders your extension's `module` JavaScript file, so `url` isn't used.

## extensions/open-email/shopify.extension.toml

```toml
api_version = "2026-04"


[[extensions]]
name = "Open email"
description = "Edit an email campaign"
handle = "open-email"
type = "ui_extension"


[[extensions.targeting]]
module = "./src/IntentExtension.jsx"
target = "admin.app.intent.render"
tools = "./tools.json"
instructions = "./instructions.md"


[[extensions.targeting.intents]]
type = "application/email"
action = "edit"
schema = "./intent-schema.json"
```

**Note:**

`admin.app.intent.render` is a special target that isn't tied to a Shopify resource and can be invoked by Sidekick from anywhere in the Shopify admin. At least one intent is required on the target. An extension without an intent will fail validation.

The `description` field helps Sidekick understand when your extension is relevant. Be specific about what your extension does. A vague description means Sidekick won't reliably invoke your extension when merchants need it. See [Writing effective extension descriptions](https://shopify.dev/docs/apps/build/sidekick#writing-effective-extension-descriptions) for detailed guidance and examples.

### Write your UI extension module

The `module` field points at your extension's JavaScript. Render your UI like any other [App Home UI extension](https://shopify.dev/docs/api/app-home), read the incoming intent payload from `shopify.intents.request.value`, and call [`shopify.intents.response.ok`](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/intents-api#response-methods) (or `.error` / `.closed`) to resolve the intent and return control to Sidekick when the merchant completes the workflow.

## extensions/open-email/src/IntentExtension.jsx

```jsx
import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useState} from 'preact/hooks';


export default async () => {
  render(<OpenEmail />, document.body);
};


function OpenEmail() {
  const {intents} = shopify;


  // `data` matches the properties declared in your intent schema's
  // `inputSchema.properties`. It's `null` outside an intent workflow.
  const {data} = intents.request.value || {};


  const [subject, setSubject] = useState(data?.subject ?? '');
  const [body, setBody] = useState(data?.body ?? '');
  const [submitting, setSubmitting] = useState(false);


  const submit = (event) => {
    setSubmitting(true);
    // Resolve the intent with the data the invoker should receive.
    intents.response.ok({subject, body});
  };


  return (
    <s-form onSubmit={submit}>
      <s-stack direction="block" gap="base">
        <s-text-field
          label="Subject"
          value={subject}
          onChange={(event) => setSubject(event.currentTarget.value)}
        />
        <s-text-area
          label="Body"
          value={body}
          onChange={(event) => setBody(event.currentTarget.value)}
        />
        <s-button type="submit" disabled={submitting}>
          {submitting ? 'Saving…' : 'Save email'}
        </s-button>
      </s-stack>
    </s-form>
  );
}
```

**UI extension callbacks aren't awaited:**

Host callbacks in UI extensions don't await returned promises. If your submit handler needs to do asynchronous work (call your app server, write to a database) before the intent resolves, pass the in-flight promise to `event.waitUntil(promise)` so Shopify keeps the surface alive until it settles. Don't make the callback itself `async` and don't `await` `shopify.intents.response.ok` — the host won't honor either one.

The schema, tools, and instructions files work identically to the admin-link target. The `app_action` template scaffolds them as `intent-schema.json`, `tools.json`, and `instructions.md`. Their contents follow the same shape as the [admin link example's schema](#declare-the-extension-schema), [tools schema](#write-your-tools-schema), and [instructions file](#write-instructions-for-using-the-app-extension).

***

## Import Shopify resources with `shopify/*` intents

The [email content editing example](#example-edit-email-content) uses an application intent (`application/email`), where your app defines the intent payload, and Sidekick navigates the merchant into your app to complete the action.

App extensions also support Shopify resource intents, where your app creates or imports Shopify-native resources and returns their [Global IDs](https://shopify.dev/docs/api/usage/gids) (GIDs). For `shopify/*` types, apps register `import` for one resource or `import+bulk` for many resources at once. Shopify's native [admin intents](https://shopify.dev/docs/apps/build/admin/admin-intents) handle creating or editing most Shopify resources. For discounts, an app can instead back the resource with a Shopify Function and register `create` and `edit` directly. See [Create and edit discounts with `shopify/Discount` intents](#create-and-edit-discounts-with-shopify-discount-intents).

Use a Shopify resource intent when your app creates or imports a Shopify resource through your own workflow. `inputSchema` carries the data your app needs, and `outputSchema` describes the GID of the resulting Shopify resource. A typical example is a print-on-demand app that creates a custom-printed Shopify Product from design details.

The Sidekick import workflows shown here omit a top-level `value`. They create or import Shopify resources, then return the created GIDs through `outputSchema`. Admin-intent handoffs that pass existing Shopify resource GIDs to partner apps are a separate invoker-side flow. See [admin intents](https://shopify.dev/docs/apps/build/admin/admin-intents).

### Choose between `application/*` and `shopify/*` intents

Use this table to choose the right intent family:

| Intent family | Use when | Examples | `$schema` to use |
| - | - | - | - |
| `application/*` | Your app owns the data shape (form fields, content payloads, free-form values) | `application/email`, `application/ticket` | `intent.json` |
| `shopify/*` | Your app creates or imports Shopify resources and returns Shopify GIDs | `shopify/Product`, `shopify/Customer`, `shopify/Order` | `shopify-intent.json` (single) or `shopify-intent-bulk.json` (bulk) |

Declare Shopify resource names in PascalCase, such as `shopify/Product`. Intent type names are compared case-insensitively for lookup and duplicate detection.

### Register a Shopify resource intent

In `shopify.extension.toml`, set `type` to a `shopify/*` resource type and `action` to `import`. Point `schema` at a JSON file that uses the `shopify-intent.json` meta-schema:

## extensions/customize-tshirt/shopify.extension.toml

```toml
[[extensions]]
name = "Customize T-shirt"
description = "Create a custom-printed T-shirt as a new Shopify Product"
handle = "customize-tshirt"
type = "admin_link"


[[extensions.targeting]]
target = "admin.app.intent.link"
url = "/customize"
tools = "./tools.json"
instructions = "./instructions.md"


[[extensions.targeting.intents]]
type = "shopify/Product"
action = "import"
schema = "./product-import-schema.json"
```

### Declare the import schema

These Sidekick import examples use two required top-level keys:

* **`inputSchema`**: your app's custom input fields. Properties become the `data` object of `intents.invoke({ data })`. Don't add `required` fields. Unlike `application/*` intents, the `shopify-intent.json` meta-schema doesn't require a `$ref` on `inputSchema`.
* **`outputSchema`**: describes the success response. For single intents, declare `properties.id` as a `$ref` to the GID schema for the resource declared as `type`. The merchant or Sidekick reads this back using [`shopify.intents.response.ok`](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/intents-api#response-methods).

The following example uses `shopify/Product` as the output resource:

## ./product-import-schema.json

```json
{
  "$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify-intent.json",
  "inputSchema": {
    "type": "object",
    "properties": {
      "title": {
        "type": "string",
        "description": "Product title (e.g. 'Custom Graphic Tee')",
        "minLength": 1,
        "maxLength": 255
      },
      "design_url": {
        "type": "string",
        "format": "uri",
        "description": "URL of the custom design image to print on the t-shirt"
      },
      "size": {
        "type": "string",
        "description": "T-shirt size",
        "enum": ["XS", "S", "M", "L", "XL", "XXL"]
      },
      "color": {
        "type": "string",
        "description": "Base t-shirt color",
        "enum": ["white", "black", "navy", "heather_gray"]
      },
      "price": {
        "type": "string",
        "description": "Product price in shop currency (e.g. '29.99')",
        "pattern": "^\\d+\\.\\d{2}$"
      }
    }
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "id": {
        "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/product/gid.json"
      }
    }
  }
}
```

### Bulk imports with `import+bulk`

To import many resources in a single intent invocation, set `action = "import+bulk"` and use the `shopify-intent-bulk.json` meta-schema. `outputSchema.properties.ids` is the array of GIDs your app returns on success.

The `+bulk` suffix identifies a bulk variant of a verb. For Shopify resource intents, `import+bulk` applies `import` to many resources at once.

Register the bulk variant with a static `url`:

## extensions/customize-tshirt-bulk/shopify.extension.toml

```toml
[[extensions]]
name = "Customize T-shirts (bulk)"
description = "Create multiple custom-printed T-shirts as new Shopify Products in a single workflow"
handle = "customize-tshirt-bulk"
type = "admin_link"


[[extensions.targeting]]
target = "admin.app.intent.link"
url = "/customize/bulk"
tools = "./tools.json"
instructions = "./instructions.md"


[[extensions.targeting.intents]]
type = "shopify/Product"
action = "import+bulk"
schema = "./product-import-bulk-schema.json"
```

Define the bulk schema with an array of output GIDs:

## ./product-import-bulk-schema.json

```json
{
  "$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify-intent-bulk.json",
  "inputSchema": {
    "type": "object",
    "properties": {
      "designs": {
        "type": "array",
        "description": "Per-product customization data. One entry for each product to create.",
        "items": {
          "type": "object",
          "properties": {
            "title": {"type": "string", "minLength": 1, "maxLength": 255},
            "design_url": {"type": "string", "format": "uri"},
            "price": {"type": "string", "pattern": "^\\d+\\.\\d{2}$"}
          }
        },
        "mapTo": "form_data",
        "fieldName": "designs"
      },
      "collection_id": {
        "type": "string",
        "description": "Optional collection to add all customized products to",
        "mapTo": "query_param",
        "fieldName": "collection_id"
      }
    }
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "ids": {
        "type": "array",
        "items": {
          "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/product/gid.json"
        }
      }
    }
  }
}
```

### Folder structure

With both variants registered, your `extensions` directory has one extension per import variant. Each extension routes to a static path for its import workflow:

## extensions/

```toml
extensions/customize-tshirt
  ├── instructions.md // Guidelines for Sidekick on tool usage
  ├── product-import-schema.json // Single-resource import schema
  ├── README.md
  ├── shopify.extension.toml // url = "/customize", action = "import"
  └── tools.json // Tool definitions for Sidekick actions


extensions/customize-tshirt-bulk
  ├── instructions.md
  ├── product-import-bulk-schema.json // Bulk import schema
  ├── README.md
  ├── shopify.extension.toml // url = "/customize/bulk", action = "import+bulk"
  └── tools.json
```

### Supported Shopify resource intents

The following `shopify/*` types are supported:

| Type | Supported actions | GID schema |
| - | - | - |
| `shopify/Customer` | `import`, `import+bulk` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/customer/gid.json` |
| `shopify/Order` | `import`, `import+bulk` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/order/gid.json` |
| `shopify/Product` | `import`, `import+bulk` | `https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/product/gid.json` |

The single-resource and bulk meta-schemas (`shopify-intent.json` and `shopify-intent-bulk.json`) are inspectable at the same `extensions.shopifycdn.com` host as the existing `intent.json` schema.

### Common mistakes to avoid

Review these common schema and configuration mistakes before you deploy. Each mistake causes a deploy-time error that points to the mismatched field or schema.

#### Wrong meta-schema for the action

`action = "import"` requires `"$schema": ".../v1/shopify-intent.json"`, and `action = "import+bulk"` requires `"$schema": ".../v1/shopify-intent-bulk.json"`. Mixing them produces a deploy-time error such as `Shopify resource type 'shopify/Product' requires $schema to be shopify-intent.json` or `... with bulk action requires $schema to be shopify-intent-bulk.json`.

#### Referencing a GID schema that isn't published

Only the resources in [Supported Shopify resource intents](#supported-shopify-resource-intents) have GID schemas you can `$ref` in `outputSchema`. Pointing at, for example, `shopify/widget/gid.json` fails because the schema doesn't reference a registered GID schema.

#### Forgetting the GID `$ref` on `outputSchema`

`outputSchema.properties.id.$ref` (single) or `outputSchema.properties.ids.items.$ref` (bulk) is required and must match the resource declared as `type` in `shopify.extension.toml`. Returning a bare string instead produces an error like `outputSchema.properties.id.$ref must contain a $ref to a Shopify GID schema`.

#### Using `application/*` and shopify-intent meta-schemas together

`application/*` types must use `intent.json`. Setting `"$schema": ".../v1/shopify-intent.json"` on an `application/email` intent produces `application type 'application/email' requires $schema to be intent.json`. The reverse is also enforced.

***

## Create and edit discounts with `shopify/Discount` intents

`shopify/Discount` lets your app create or edit discounts backed by a [Shopify Function](https://shopify.dev/docs/apps/build/functions). Like the [import intents above](#import-shopify-resources-with-shopify--intents), it uses the `shopify-intent.json` meta-schema, but it doesn't run an import: `create` and `edit` open your app's discount create or edit surface for the merchant. Register discount intents on an `admin.app.intent.link` target, using the app-link flow shown below.

**Discount UI extensions are displayed automatically:**

If your discount Shopify Function includes a UI extension on the discount settings target ([`admin.discount-details.function-settings.render`](https://shopify.dev/docs/api/admin-extensions/latest/target-apis/contextual-apis/discount-function-settings-api)), then Shopify displays that discount in the discount type picker, so you don't need to register a `shopify/Discount` app intent link for it. Register a `shopify/Discount` app intent link when your React Router app provides the create or edit page instead.

### Register a discount intent

Set `type = "shopify/Discount"` and `action` to `create` or `edit` in `shopify.extension.toml`. Point `url` at any stable, unique page in your app for this discount type. The path is entirely yours to choose. It's where Shopify sends the merchant when the intent opens.

## extensions/create-discount/shopify.extension.toml

```toml
[[extensions]]
name = "Create discount"
handle = "create-discount"
type = "admin_link"


[[extensions.targeting]]
target = "admin.app.intent.link"
# Any stable, unique URL in your app for this discount type. The path is
# yours to choose. This is where the merchant lands when the intent opens.
url = "/app/discounts/volume/new"
instructions = "./instructions.md"


[[extensions.targeting.intents]]
type = "shopify/Discount"
action = "create"
schema = "./discount-schema.json"
```

**Tools are optional:**

A discount intent link only has to open your page, so this example declares no `tools`. Add a `tools.json` and [register handlers at runtime](#bind-tool-handlers-at-runtime) only when you want Sidekick to fill in or adjust the discount form after the page opens.

### Declare the discount schema

Use the `shopify-intent.json` meta-schema and `$ref` the discount baseline (`shopify/discount.json`) from your `inputSchema`. The baseline supplies `functionId` so you don't need to redeclare it. Add your own properties alongside.

## ./discount-schema.json

```json
{
  "$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify-intent.json",
  "inputSchema": {
    "type": "object",
    "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount.json",
    "additionalProperties": true,
    "properties": {
      "functionId": {
        "matchValue": "YOUR_FUNCTION_ID"
      }
    }
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "id": {
        "$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount/gid.json"
      }
    }
  }
}
```

* **`functionId`**: set `matchValue` to the `id` of the Shopify Function that backs this discount. Look it up with the [`shopifyFunctions`](https://shopify.dev/docs/api/admin-graphql/latest/queries/shopifyFunctions) Admin API query and use the returned `node.id` (a UUID such as `01234567-89ab-cdef-0123-456789abcdef`). With `matchValue` set, a `create:shopify/Discount` invocation carrying a matching `functionId` resolves straight to this intent. Without a match, the invocation opens the discount type picker (see [`matchValue`](#matchvalue)). `functionId` is also passed to your app as intent data. To transport it into your `url` or query string, add a `mapTo` such as `param` or `query_param`.
* **`outputSchema.properties.id`**: `$ref` the discount GID schema (`shopify/discount/gid.json`), which accepts `DiscountNode`, `DiscountCodeNode`, and `DiscountAutomaticNode` GIDs.

For `edit:shopify/Discount`, also declare a top-level `value` that `$ref`s `shopify/discount/gid.json` to receive the GID of the discount being edited.

### Help merchants choose a discount intent

Native discount types and app-provided discounts share the `create:shopify/Discount` intent, so invoking it without a `type` or a matching `functionId` opens the discount type picker that lists the native discount types alongside eligible app discounts. Merchants encounter this picker more often for discounts than for other resources, so make each intent easy to choose:

* Point each intent at a `url` that resolves to a single discount type. Register a separate discount intent per discount type, within your app's [intent limit](https://shopify.dev/docs/apps/build/sidekick#limits). Avoid a single intent that relies on an unresolved placeholder to represent many functions: it appears in the picker but has no page to open.
* Set `matchValue` on `functionId` so an invocation that passes a matching `functionId` narrows straight to your intent and skips the picker.
* The picker uses your Function extension's title and icon, so keep them clear and specific.

***
