Skip to content

Engineering

Building a Shopify Sidekick app extension: what the docs understate

By Jahangir Alam · July 4, 2026 · 10 min read

Short version: a Sidekick app extension is three layers - a declaration (tools.json + a one-line app-level summary), a thin runtime that registers tools and calls your backend, and your existing API. The docs explain the shapes; they understate the operational reality. The two things that cost us the most time: Sidekick only routes to your tools if your app-level extensions_summary fits inside a hard limit, and a result only becomes openable if its deep link is a first-class url field - not tucked inside _meta. This post is the walkthrough we wish we'd read first, from shipping the integration in QuotWay, our B2B quote app.

What a Sidekick app extension actually is

Sidekick is the AI assistant in the Shopify admin. An app extension lets Sidekick call your app to answer a merchant's question - "which quotes are waiting for approval?" - and render the result inline, without the merchant leaving the page.

There are two kinds, and the distinction matters before you write a line of code:

Data extension Action extension
Target admin.app.tools.data admin.app.intent.link / admin.app.intent.render
Does Read: search, look up, summarize Write: stage a change the merchant confirms
Intent vocabulary None - free-form tools Fixed Shopify vocabulary (email, ticket, review, …)
Ship today? Yes, for any app Only if a matching intent type exists

We shipped a data extension with 15 read-only tools (search quotes, pipeline KPIs, pending approvals, company insights, and so on). We did not ship an action extension - and that's a deliberate limitation worth understanding early (more below).

The mental model: three layers

Almost every mistake we made came from conflating these. Keep them separate:

1. Declaration (what Sidekick reads to decide whether to call you). Two files plus one app-level string:

  • shopify.app.toml gets a [sidekick] block with an extensions_summary - a plain-language description of what your app can answer. This is the router. Sidekick reads it to decide whether a given question belongs to your app at all.
  • tools.json declares each tool: name, description, inputSchema. The descriptions are how Sidekick picks which tool to call.
  • instructions.md is guidance handed to Sidekick on how to use the results.
# shopify.app.toml
[sidekick]
extensions_summary = "QuotWay answers questions about B2B quotes: search and filter by status/buyer/company/amount, look up a quote's status and next step, list quotes pending approval, report pipeline analytics, and summarize B2B companies."
# extensions/sidekick-data/shopify.extension.toml
api_version = "2026-04"

[[extensions]]
name = "QuotWay quote tools"
handle = "quotway-sidekick-data"
type = "ui_extension"

  [[extensions.targeting]]
  module = "./src/index.js"
  target = "admin.app.tools.data"
  tools = "./tools.json"
  instructions = "./instructions.md"

2. Runtime (the sandboxed glue). Your module runs headlessly in Shopify's sandbox. It registers each declared tool and, when Sidekick calls one, fetches your backend and maps the response into Sidekick's result shape. Keep it thin - no business logic here:

export default () => {
  shopify.tools.register("pending_approvals", async () => {
    const res = await fetch("/api/sidekick/quotes?status=awaiting_approval", {
      headers: { Accept: "application/json" },
    });
    const data = res.ok ? await res.json() : null;
    return { results: (data?.quotes ?? []).map(quoteLink) };
  });
};

3. Backend (your app's real API). The sandbox fetches your own endpoints on the same domain, and Shopify attaches the merchant's session token - so your existing auth, tenant scoping, and permission checks apply unchanged. We added a small family of /api/sidekick/* routes that reuse the same session-token auth and RBAC as the rest of the app:

export const loader = async ({ request }) =>
  withAdminShopContext(request, async ({ shopId, staff }) => {
    requirePermission(staff.role, "quote.view");
    // ...query, shape a plain DTO, return CORS-enabled JSON
  });

The sandbox calls are cross-origin, so every route answers a CORS preflight (OPTIONS → 204) and returns permissive CORS headers. A wildcard Access-Control-Allow-Origin is safe here because auth rides a Bearer session token, not cookies.

Data tools return Model Context Protocol resource links:

{
  "results": [
    {
      "type": "resource_link",
      "uri": "gid://application/quotway.quote/123",
      "name": "QW-1042 — Acme Co.",
      "mimeType": "application/vnd.quotway.quote",
      "_meta": { "status": "Proposal sent", "total": "USD 5,000.00" }
    }
  ]
}

Here is the single most important thing we learned, and it is barely a sentence in the docs: _meta is display-only summary data. It is not a navigation target. We first put the deep link in _meta.url - the card rendered fine, but clicking it did nothing. Sidekick had no way to open the resource, because our uri was a synthetic gid://application/... it can't resolve, and the real URL was buried in metadata it treats as decoration.

The fix is a first-class url field on the result, using the app: protocol for an embedded app:

function quoteLink(q) {
  return {
    type: "resource_link",
    uri: `gid://application/quotway.quote/${q.id}`,
    name: `${q.reference} — ${q.buyerName}`,
    mimeType: "application/vnd.quotway.quote",
    url: `app://app/quotes/${q.id}`, // ← first-class, openable
    _meta: { status: q.statusLabel, total: q.total },
  };
}

Shopify resolves app://<path> against your embedded app's base URL and navigates straight into your app - no store handle, no API key, no absolute URL construction. (An absolute admin.shopify.com/store/<store>/apps/<client_id>/<path> works too, but app: is simpler and store-agnostic.) With the url promoted out of _meta, "show me quote QW-1007" now returns the summary and a link that opens that exact quote in the app.

The gotchas (the stuff that cost us a day)

Symptom Cause Fix
Sidekick answers from generic knowledge; zero calls hit your backend extensions_summary exceeds the deploy validator's limit and is silently dropped, so the [sidekick] block never registers Keep the summary within the enforced size (for us the real ceiling was 500 characters, not the "256 tokens" the docs imply). Confirm by checking your server logs for /api/sidekick hits - zero hits means not routed, not a backend bug.
The result card shows but the link doesn't open Deep link is in _meta, or uri is an unresolvable gid://application/... Put an app://<path> (or absolute) URL in a first-class url field
"It's broken" right after deploying Sidekick tool availability is per-session / propagation-delayed Retry in a brand-new Sidekick chat; don't conclude it's broken from a stale session
Backend tool calls 404 / CORS-fail in production You deployed the extension but not the backend routes (or vice-versa) Deploy both together: extension via shopify app deploy, backend routes via your host
Push to your host silently doesn't deploy (Host-specific) e.g. a "require verified commits" setting cancels unsigned pushes Sign your commits so the backend actually ships alongside the extension
CLI preview behaves differently than expected The tools route through the real admin Test in the admin Sidekick, not CLI preview

Two of those are worth expanding.

Diagnose routing with your own logs. When Sidekick "doesn't work," the fastest signal is whether your backend was called at all. We grepped production logs for /api/sidekick and saw zero hits - which immediately ruled out CORS, auth, and backend bugs and pointed us at the router (extensions_summary). Silence at the backend means the question never got routed to your app.

Descriptions are the product. Sidekick's routing is a language-model decision over your extensions_summary and tool descriptions. Vague descriptions get skipped. Load them with the exact words a merchant would use ("quotes", "RFQs", "pending approval", "conversion rate", "pipeline") - but stay factual. Shopify's content policy scans these at deploy and runtime and forbids upsells, competitor comparisons, and instructions that try to steer Sidekick's behavior. We had to delete an instructions.md line that told Sidekick what to say; describe your data, don't script the assistant.

Read is shippable today; write is gated

This is the strategic limitation to plan around. A data extension (read) works for any app right now. An action extension (write - create, edit, send, convert) binds to a fixed Shopify intent vocabulary, and shopify app deploy rejects an intent type that isn't in it. For a quote-and-negotiation app there is no application/quote type yet, so we can't ship a native "send this proposal from Sidekick" action.

The pragmatic bridge - confirmed by Shopify staff on the dev forum - is the url field above: you can't act from inside Sidekick, but you can hand it an openable deep link so the merchant lands on the exact page to act, one click away. If you're in the same boat, the right move is to file a proposal for your intent type in Shopify's app-intent-types RFC repo and ship the read + deep-link experience now. We did both.

Constraints worth memorizing

  • Response budget: keep each tool response under 4,000 tokens and aim for under 1 second. These are read tools answering a chat turn - return what was asked, not your whole object graph.
  • Per-app budget: you get a bounded number of tools (and intents). Model them around real merchant questions, not around your database tables.
  • Auth is free: the sandbox reuses your app's session, so lean on your existing tenant scoping and permission checks. A read tool should be exactly as locked-down as the page it mirrors.

A reasonable first milestone

  1. One data extension, two tools that map to real questions ("search X", "look up one X").
  2. A [sidekick] extensions_summary inside the size limit, plus keyword-rich, factual tool descriptions.
  3. Thin sandbox glue that fetches two /api/* endpoints reusing your existing auth + CORS preflight.
  4. A first-class url on each result so it opens the right in-app page.
  5. Deploy the extension and the backend together; test in the real admin in a fresh chat.

That's a day of work for a genuinely useful integration - most of which is reusing the API you already have. The hard part isn't the code; it's the three or four operational facts above.

Further reading

See how QuotWay handles this on your store.