Skip to main content

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.


Anchor to Example: Edit email contentExample: 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 app, navigated to the correct email campaign.

Sidekick with email app

Anchor to Expose actions to SidekickExpose 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 typeTargetApp Home supportStandalone app support
Admin linkadmin.app.intent.link✅❌
UI extensionadmin.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 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.

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).


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.

Anchor to Create an app extensionCreate an app extension

Use the Shopify CLI 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.)

App action link

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

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

[[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 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 for the full mechanism.

Anchor to Register your extension as an intentRegister 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. Don't pick an unrelated type as a placeholder.

extensions/open-email/shopify.extension.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.

TypeDescriptionSupported actionsSchema reference
application/adAd campaignscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/ad.json
application/ad-groupAd groups (ad sets) that organize ads within a campaigncreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/ad-group.json
application/alert-ruleStore event alerts that notify the merchant's team, such as low-stock, high-value order, and refund alertscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/alert-rule.json
application/campaignMarketing campaignscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/campaign.json
application/checkout-ruleApp-owned checkout customization rulescreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/checkout-rule.json
application/comparison-tableMerchant-authored comparison tables shown on the storefront, such as "us vs. them" brand tables and product-vs-product tablescreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/comparison-table.json
application/culinary-recipeFood and drink recipescreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/culinary-recipe.json
application/emailEmail campaignscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/email.json
application/experimentExperiments and A/B testscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/experiment.json
application/faqFAQ managementcreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/faq.json
application/label-templateProduct, price, barcode, QR, and hangtag label designscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/label-template.json
application/legal-pageLegal and privacy pages, such as regional policies and privacy-request pagescreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/legal-page.json
application/loyalty-programLoyalty programscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/loyalty-program.json
application/optimization-planOptimization planscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/optimization-plan.json
application/pagePages authored and maintained in an app, such as a page buildercreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/page.json
application/product-specificationProduct specification datacreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/product-specification.json
application/quoteQuotes for B2B quote and negotiation appscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/quote.json
application/returnReturns managementcreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/return.json
application/reviewProduct reviewscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/review.json
application/shipmentShipment trackingcreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/shipment.json
application/storefront-popupApp-managed storefront popups and embedded signup formscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/storefront-popup.json
application/ticketSupport ticketscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/ticket.json
application/visitor-restrictionApp-owned storefront access rules, such as country blocks and IP allowlistscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/application/visitor-restriction.json
shopify/content-localizationLocalize Shopify store contentedithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/content-localization.json
shopify/CustomerShopify customersimport, import+bulkhttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/customer/gid.json
shopify/DiscountShopify discountscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount.json
shopify/InventoryTransferShopify inventory transferscreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/inventorytransfer.json
shopify/OnlineStoreThemeOnline store themesedithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/onlinestoretheme.json
shopify/OrderShopify ordersimport, import+bulkhttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/order/gid.json
shopify/ProductShopify productsimport, import+bulkhttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/product/gid.json
shopify/ProductBundleShopify product bundlescreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/productbundle.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. shopify/OnlineStoreTheme supports only edit, for an existing online store theme identified by its gid://shopify/OnlineStoreTheme/{id} GID. shopify/InventoryTransfer supports create and edit for Shopify inventory transfers. shopify/content-localization supports edit to open your app's translation editor for Shopify store content. For import schema examples, see Import Shopify resources with shopify/* intents. For discount, inventory transfer, theme, and content localization schema examples, see Create and edit discounts with shopify/Discount intents, Create and edit inventory transfers with shopify/InventoryTransfer intents, Edit online store themes with shopify/OnlineStoreTheme intents, and Localize Shopify content with shopify/content-localization intents.

shopify/ProductBundle supports create and edit for apps that build bundles backed by a Shopify Product. The intent opens your app's bundle composition editor for that Product, not a full Product editor.

If you'd like Shopify to support another type, then discuss or propose it in the Shopify app intent types repository.

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.

Anchor to Declare the extension schemaDeclare 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 for the URL transport mechanism, and matchValue for the disambiguation mechanism.

./email-schema.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

Anchor to Leaving the URL placeholder unmappedLeaving 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 for the valid patterns.

The inputSchema block uses JSON Schema 2020-12, 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.

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.

Anchor to Write your tools schemaWrite 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 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. 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 for a tool that does this). Keep the two schemas distinct and don't conflate them.

./tools.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).


Anchor to Map intent values into the URLMap 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.

Anchor to How placeholder substitution worksHow 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 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.

When the value mapped to a param placeholder is a GID (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 instead of the URL.

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:

mapToEffectExample
form_dataSends 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
hashAppends the value to the URL's hash fragment./app/campaigns#edit
paramSubstitutes the value into a {placeholder} in the URL path./app/campaigns/{id} → /app/campaigns/4353532
query_paramAppends 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 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:

"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 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:

// 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"
}
}
}
// 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.

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

[[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

{
"$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.

Anchor to Example: rename a field with ,[object Object]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

{
"$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.


Anchor to Bind tool handlers at runtimeBind tool handlers at runtime

After declaring tools statically in tools.json, use the 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 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:

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 for the full surface, including unregister, clear, and additional lifecycle examples.


Anchor to Read the incoming intentRead the incoming intent

When your extension opens for an app intent, read the incoming payload from shopify.intents.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.

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 section of the Intents API reference for the full payload shape.


When a merchant finishes, fails, or cancels the workflow your extension renders for an app intent, resolve the intent using the Intents API response methods. This returns control to the surface that invoked your intent and delivers the result.

// 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 for the full response surface.


Anchor to Write instructions for using the app extensionWrite 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

## 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.


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.

Anchor to Render different UI states at the same pathnameRender 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

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) to resolve the intent. The invoking surface closes the modal, so you don't need to navigate yourself.


Anchor to End-to-end example: search, open, and edit an email campaignEnd-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 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", …).
  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.

search_campaigns lives in the data extension you built in the data extension guide. Its handler returns resource links whose mimeType matches the intent type declared in the next step:

extensions/tools/src/index.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.

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

[[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, 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 earlier on this page.

Anchor to 3. The app route registers the in-page tool3. 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 for the contract.

app/routes/edit.$id.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.

Anchor to Verify the round trip locallyVerify 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.
  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.

Anchor to Putting it all togetherPutting it all together

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

Folder structure

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

Anchor to Example: Allow Sidekick to edit email content with a UI extensionExample: 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 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.

Anchor to Create an app extensionCreate an app extension

Use the Shopify CLI to create a Sidekick app action UI extension.

App action UI extension

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

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

Anchor to Configure the UI extension targetConfigure 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

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 for detailed guidance and examples.

Anchor to Write your UI extension moduleWrite your UI extension module

The module field points at your extension's JavaScript. Render your UI like any other App Home UI extension, read the incoming intent payload from shopify.intents.request.value, and call shopify.intents.response.ok (or .error / .closed) to resolve the intent and return control to Sidekick when the merchant completes the workflow.

extensions/open-email/src/IntentExtension.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, tools schema, and instructions file.


Anchor to Import Shopify resources with ,[object Object], intentsImport Shopify resources with shopify/* intents

The email content editing example 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, edits, or imports Shopify-native resources and returns their Global IDs (GIDs). For most shopify/* types, apps register import for one resource or import+bulk for many resources at once. Shopify's native admin intents handle creating or editing most Shopify resources. There are five exceptions:

Use a Shopify resource intent when your app creates, edits, 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. Create workflows can also omit value. Edit workflows receive the existing resource GID in value. If the target URL contains a placeholder, map value to it. Admin-intent handoffs that pass existing Shopify resource GIDs to partner apps are a separate invoker-side flow. See admin intents.

Anchor to Choose between ,[object Object], and ,[object Object], intentsChoose between application/* and shopify/* intents

Use this table to choose the right intent family:

Intent familyUse whenExamples$schema to use
application/*Your app owns the data shape (form fields, content payloads, free-form values)application/email, application/ticketintent.json
shopify/*Your app imports, creates, edits, or localizes Shopify resources identified by Shopify GIDsshopify/Product, shopify/Discount, shopify/OnlineStoreTheme, shopify/content-localizationshopify-intent.json (single) or shopify-intent-bulk.json (bulk)

Use the registered type name. Shopify resource names such as shopify/Product use PascalCase, while shopify/content-localization uses a lowercase, hyphenated name. Intent type names are compared case-insensitively for lookup and duplicate detection.

Anchor to Register a Shopify import intentRegister a Shopify import 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

[[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"

Anchor to Declare the import schemaDeclare 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.

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

./product-import-schema.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"
}
}
}
}

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

[[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

{
"$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"
}
}
}
}
}

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/

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

Anchor to Supported Shopify resource intentsSupported Shopify resource intents

The following shopify/* types support import workflows:

TypeSupported actionsGID schema
shopify/Customerimport, import+bulkhttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/customer/gid.json
shopify/Discountcreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/discount/gid.json
shopify/InventoryTransfercreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/inventorytransfer/gid.json
shopify/OnlineStoreThemeedithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/onlinestoretheme/gid.json
shopify/Orderimport, import+bulkhttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/order/gid.json
shopify/Productimport, import+bulkhttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/product/gid.json
shopify/ProductBundlecreate, edithttps://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/product/gid.json

shopify/ProductBundle uses the shopify-intent.json meta-schema and references the shopify/productbundle.json baseline from inputSchema. The optional top-level value for edit and outputSchema.properties.id reference the shopify/product/gid.json schema because the bundle is backed by a Shopify Product. If an invocation doesn't identify a bundle, then your app must resolve or ask the merchant to select it before editing. The shared baseline adds no required fields and doesn't duplicate an input id.

shopify/Customer, shopify/Order, and shopify/Product support only import actions. shopify/Discount and shopify/InventoryTransfer support only create and edit. 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.

Anchor to Common mistakes to avoidCommon 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.

Anchor to Wrong meta-schema for the actionWrong 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.

Anchor to Referencing a GID schema that isn't publishedReferencing a GID schema that isn't published

Use a published GID schema for the type's backing resource, as shown in Supported Shopify resource intents. Only those resources have GID schemas you can $ref in value or outputSchema. Pointing at, for example, shopify/widget/gid.json fails because the schema doesn't reference a registered GID schema.

outputSchema.properties.id.$ref (single) or outputSchema.properties.ids.items.$ref (bulk) is required and must match the intent type's configured backing resource. For shopify/ProductBundle, that resource is a Product, so use shopify/product/gid.json. Returning a bare string instead produces an error like outputSchema.properties.id.$ref must contain a $ref to a Shopify GID schema.

Anchor to Using ,[object Object], and shopify-intent meta-schemas togetherUsing 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.


Anchor to Localize Shopify content with ,[object Object], intentsLocalize Shopify content with shopify/content-localization intents

Register edit:shopify/content-localization to open your app's translation editor for Shopify store content. Use edit both to add missing translations and to revise existing translations. This type doesn't support create, import, or a separate publish action.

Anchor to Register a content localization intentRegister a content localization intent

Use a stable editor URL that also works when the caller doesn't supply a locale or a specific content item:

extensions/localize-content/shopify.extension.toml

[[extensions]]
name = "Localize store content"
description = "Translate and review Shopify store content"
handle = "localize-content"
type = "admin_link"

[[extensions.targeting]]
target = "admin.app.intent.link"
url = "/app/localize"

[[extensions.targeting.intents]]
type = "shopify/content-localization"
action = "edit"
schema = "./content-localization-schema.json"

Anchor to Declare the content localization schemaDeclare the content localization schema

Use shopify-intent.json and reference the published shopify/content-localization.json baseline from inputSchema. The baseline contains only an optional locale string and permits additional properties. Declare URL mappings in your own schema:

./content-localization-schema.json

{
"$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify-intent.json",
"inputSchema": {
"type": "object",
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/content-localization.json",
"properties": {
"locale": {
"type": "string",
"mapTo": "query_param"
}
}
},
"outputSchema": {
"type": "object",
"properties": {
"id": {
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/shop/gid.json"
}
}
}
}

This whole-store example doesn't declare a top-level value, so callers can invoke it without one. If your registration needs the caller to identify the Shop, add a top-level value that references shopify/shop/gid.json. A declared value is validated on every invocation, so callers of that registration must always pass the Shop GID, and you read it from the incoming intent payload. The value identifies the Shop, not an individual translation or product. Either way, declare the Shop GID reference for outputSchema.properties.id.

Add optional resource, field, market, or batch targeting to your app's extending schema when needed. These inputs aren't shared baseline fields. Don't add a second ID for the Shop in inputSchema, and don't declare required input fields.

Opening the editor doesn't authorize translation writes, publication, or charges. Stage changes for user review and confirmation. Keep these operations and job progress in your app's workflow. Add in-page tools only when your embedded app supports them, and bind their handlers at runtime. Test registration, discovery, and invocation with your app before shipping.


Anchor to Create and edit discounts with ,[object Object], intentsCreate and edit discounts with shopify/Discount intents

shopify/Discount lets your app create or edit discounts backed by a Shopify Function. Like the import intents above, 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), 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.

Anchor to Register a discount intentRegister 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

[[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 only when you want Sidekick to fill in or adjust the discount form after the page opens.

Anchor to Declare the discount schemaDeclare 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

{
"$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 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). 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 $refs shopify/discount/gid.json to receive the GID of the discount being edited.

Anchor to Help merchants choose a discount intentHelp 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. 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.

Anchor to Edit online store themes with ,[object Object], intentsEdit online store themes with shopify/OnlineStoreTheme intents

shopify/OnlineStoreTheme lets your app open one of the merchant's existing online store themes in your app's editing workflow. It supports only edit. There's no create, import, or bulk variant. Like discount intents, it uses the shopify-intent.json meta-schema and opens your app for the merchant instead of running an import. Register theme intents on an admin.app.intent.link target.

The intent doesn't grant theme access

A shopify/OnlineStoreTheme intent only routes the merchant to your app. It doesn't give your app permission to change the theme, and it doesn't exempt your app from any theme requirement. To change the theme, your app still needs its own theme access, such as the write_themes access scope and any approval Shopify requires for theme changes.

Anchor to Register a theme edit intentRegister a theme edit intent

Set type = "shopify/OnlineStoreTheme" and action = "edit" in shopify.extension.toml. Put an {id} placeholder in url so that the theme being edited reaches your page:

extensions/edit-theme/shopify.extension.toml

[[extensions]]
name = "Edit theme"
handle = "edit-theme"
type = "admin_link"

[[extensions.targeting]]
target = "admin.app.intent.link"
# {id} is filled from the intent's `value`, the GID of the theme being edited.
url = "/app/themes/{id}/edit"
instructions = "./instructions.md"

[[extensions.targeting.intents]]
type = "shopify/OnlineStoreTheme"
action = "edit"
schema = "./theme-schema.json"

Anchor to Declare the theme schemaDeclare the theme schema

Use the shopify-intent.json meta-schema and $ref the theme baseline (shopify/onlinestoretheme.json) from your inputSchema. The baseline is an empty object that allows additional properties, so it doesn't define any standard input fields. Add your own properties alongside it.

./theme-schema.json

{
"$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify-intent.json",
"inputSchema": {
"type": "object",
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/onlinestoretheme.json"
},
"value": {
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/onlinestoretheme/gid.json",
"mapTo": "param",
"fieldName": "id"
},
"outputSchema": {
"type": "object",
"properties": {
"id": {
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/onlinestoretheme/gid.json"
}
}
}
}
  • value: $ref the theme GID schema (shopify/onlinestoretheme/gid.json) to receive the GID of the theme being edited, such as gid://shopify/OnlineStoreTheme/123. With mapTo: "param" and fieldName: "id", Sidekick substitutes the theme's bare ID into the {id} placeholder (see Working with GIDs). A value that $refs another resource's GID schema, such as shopify/product/gid.json, fails at deploy.
  • outputSchema.properties.id: $ref the same theme GID schema, and return the GID of the theme your app edited.

Anchor to Create and edit inventory transfers with ,[object Object], intentsCreate and edit inventory transfers with shopify/InventoryTransfer intents

Use shopify/InventoryTransfer when your app provides a workflow to create or edit a Shopify inventory transfer. It supports only create and edit, not import, import+bulk, or cancel. Register a separate intent for each action.

Use a static URL for create:

extensions/create-inventory-transfer/shopify.extension.toml

[[extensions]]
name = "Create inventory transfer"
handle = "create-inventory-transfer"
type = "admin_link"

[[extensions.targeting]]
target = "admin.app.intent.link"
url = "/inventory-transfers/new"
instructions = "./instructions.md"

[[extensions.targeting.intents]]
type = "shopify/InventoryTransfer"
action = "create"
schema = "./inventory-transfer-create-schema.json"

For edit, map the top-level value to the URL placeholder:

extensions/edit-inventory-transfer/shopify.extension.toml

[[extensions]]
name = "Edit inventory transfer"
handle = "edit-inventory-transfer"
type = "admin_link"

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

[[extensions.targeting.intents]]
type = "shopify/InventoryTransfer"
action = "edit"
schema = "./inventory-transfer-edit-schema.json"

Give each action its own schema. A transfer doesn't exist yet when create runs, so the create schema has no top-level value. A declared value is validated on every invocation, so a create schema that declares one rejects calls that have no transfer GID to pass. Don't add id to inputSchema. The baseline has no shared input properties. Any provider-specific inputs must be optional.

./inventory-transfer-create-schema.json

{
"$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify-intent.json",
"inputSchema": {
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/inventorytransfer.json",
"type": "object"
},
"outputSchema": {
"type": "object",
"properties": {
"id": {
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/inventorytransfer/gid.json"
}
}
}
}

The edit schema adds a top-level value that callers must pass: the existing transfer's GID, in the form gid://shopify/InventoryTransfer/<id>. URL mapping uses the final ID segment for {id}. If your edit workflow starts without a specific transfer, register it with a schema that has no value and a static resource-selection URL instead.

./inventory-transfer-edit-schema.json

{
"$schema": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify-intent.json",
"value": {
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/inventorytransfer/gid.json",
"mapTo": "param",
"fieldName": "id"
},
"inputSchema": {
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/inventorytransfer.json",
"type": "object"
},
"outputSchema": {
"type": "object",
"properties": {
"id": {
"$ref": "https://extensions.shopifycdn.com/shopifycloud/schemas/v1/shopify/inventorytransfer/gid.json"
}
}
}
}

Before changing inventory, authorize the shop, staff member, and referenced resources, reload current state, and ask the merchant to confirm the change. Creating, shipping, receiving, and canceling a transfer are separate operations inside the recipient workflow, not additional intent actions. Follow the GraphQL Admin API inventory transfer create requirements, including idempotency handling.

When the workflow succeeds, return the inventory transfer GID:

await shopify.intents.response.ok({
id: 'gid://shopify/InventoryTransfer/123',
});

Was this page helpful?