skills/metabase-static-embedding-to-guest-embedding-upgrade/SKILL.md
Migrates a project from Metabase static embedding to guest embeds (web components via embed.js). Use when the user wants to migrate/convert/switch/upgrade from static embedding to guest embeds, from signed embed iframes to web components, or replace /embed/ iframes with metabase-dashboard/metabase-question components.
npx skillsauth add metabase/agent-skills metabase-static-embedding-to-guest-embedding-upgradeInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Follow the workflow steps in order — do not skip any step. Create the checklist first, then execute each step and explicitly mark it done with evidence. Each step's output feeds into the next, so skipping steps produces wrong migrations.
If you cannot complete a step due to missing info or tool failure, you must:
Your response should contain these sections in this order:
Each step section should end with a status line:
Status: ✅ complete or Status: ❌ blockedSteps are sequential — do not start a step until the previous one is ✅ complete.
Follow the app's existing architecture, template engine, layout/partial system, code style, and route patterns. Do not switch paradigms (e.g., templates to inline HTML or vice versa). If the app has middleware for shared template variables, prefer that over duplicating across route handlers.
The web component must be rendered using the same delivery mechanism as the static iframe it replaces. If the iframe was rendered by a server-side template (EJS, Jinja, ERB, Blade, etc.), the web component should be rendered by the same template. If the iframe was returned as inline HTML from a route handler (e.g., res.send('<iframe ...')), the web component should be returned the same way. If the iframe was in a static HTML file, the web component goes in that same file. Do not move rendering from one layer to another — the migration should be a drop-in replacement at the same point in the rendering pipeline.
Token delivery must use the same mechanism as the original static embedding. If the JWT was rendered server-side into the HTML (e.g., res.send(\<iframe src=".../${token}">`)), the migrated web component should receive its token the same way — rendered server-side into the tokenattribute (e.g.,<metabase-dashboard token="${token}">). If the JWT was fetched client-side via fetch(), keep using fetch()` for the token. Do not change the delivery mechanism — just change what is delivered (raw token instead of full iframe URL).
This migration touches code that handles METABASE_SECRET_KEY and signed JWTs. Rules:
config.js:12"), never echo the value itselfMETABASE_SECRET_KEY, token) — not the resolved valuesThis skill converts static (signed) iframe embedding to guest embeds (web-component-based via embed.js). Both approaches use the same authentication model — signed JWTs with METABASE_SECRET_KEY — so the backend signing logic is preserved. The migration changes how the signed content is delivered: from iframes with JWT-in-URL to web components with a token attribute.
The consumer's app may be written in any backend language (Node.js, Python, Ruby, PHP, Java, Go, .NET, etc.) with any template engine. Keep instructions language-agnostic unless a specific language is detected in Step 1.
<iframe> elements (/embed/dashboard/{JWT}, /embed/question/{JWT}) with web components (<metabase-dashboard token="...">, <metabase-question token="...">)embed.js script tag (exactly once at app layout level)window.metabaseConfig with isGuest: true (exactly once at app layout level)#titled=true, #bordered=true) to web component attributesMETABASE_SECRET_KEY using the same {resource, params} payloadtoken attribute)params in the JWT to initial-parameters attribute where applicableiframeResizer.js references if presentThe auth model is the same — both use METABASE_SECRET_KEY to sign JWTs with {resource, params, exp}. What changes is how the embed is rendered:
| Aspect | Static embedding (iframe) | Guest embeds (web component) |
|---|---|---|
| Element | <iframe src="/embed/dashboard/{JWT}#params"> | <metabase-dashboard token="{JWT}"> |
| Token delivery | Baked into iframe URL path | Passed as token attribute |
| Config | None (iframe is self-contained) | window.metabaseConfig = { isGuest: true, instanceUrl: "..." } |
| Script | Optional iframeResizer.js | Required embed.js |
| Appearance | Hash params (#titled=true) | Component attributes (with-title="true") |
| Locked params | In JWT params field | Same JWT params field (unchanged) |
| Secret key | METABASE_SECRET_KEY | Same METABASE_SECRET_KEY |
Guest embeds support additional attributes (e.g., downloads, drill-through, hidden parameters) not available in static embedding. Consult the fetched docs for the full list of available attributes for the target version.
Fetch the version-specific llms-embedding-full.txt using this URL:
https://www.metabase.com/docs/v0.{VERSION}/llms-embedding-full.txt
The version in the URL uses the format v0.58 (normalize: strip leading v or 0., drop patch — e.g., 0.58.1 → 58 → URL uses v0.58). This single file contains all embedding documentation for that version, optimized for LLM consumption.
Other constraints:
Use AskUserQuestion and halt until answered if:
Create a checklist to track progress. In Claude Code, use TaskCreate/TaskUpdate tools:
Before anything else, determine the Metabase version. Grep the project for Docker image tags (metabase/metabase:v, metabase/metabase-enterprise:v), METABASE_VERSION, or version references. If undetected, AskUserQuestion (options: v53 or older, v54–v58, v59+). Abort if < v53 (modular embedding not available). Record the version.
Perform the project scan and doc fetch concurrently — they are independent. Use parallel tool calls within a single message wherever there are no dependencies.
Fetch llms-embedding-full.txt for the target version (see "Allowed documentation sources" for URL format). These docs are the authoritative source for web component attributes, window.metabaseConfig options, and guest embedding configuration for the target version. Use them in Step 2 for mapping instead of relying on hardcoded tables alone.
Launch this concurrently with the project scan steps below.
package.json, requirements.txt, Gemfile, pom.xml, go.mod, composer.json, etc.).Grep for these patterns (in parallel) to detect if the app already has modular embedding configured:
/app/embed.js — existing embed.js script tagwindow.metabaseConfig — existing config assignmentRecord whether each is already present and where. If both already exist (e.g., the app uses modular embedding alongside static embedding), Steps 3a and 3b will skip adding them.
Use Grep to search for all of these patterns (in parallel):
/embed/dashboard/ in all files — static embed dashboard URLs/embed/question/ in all files — static embed question URLs<iframe in all template/HTML/JSX/view files — the embed elementsMETABASE_SECRET_KEY or METABASE_EMBEDDING_SECRET_KEY — the signing secretresource: near dashboard or question — JWT payload structureiframeResizer — optional auto-resize scriptFor each file with a match, read the entire file.
Use Grep to search for all of these patterns (in parallel):
jwt.sign or jwt.encode or JWT or jsonwebtoken or PyJWT or joseMETABASE_SECRET_KEY or MB_EMBEDDING_SECRET_KEYresource: combined with params: (the static embed JWT payload shape)For each matching file, read the entire file.
Find the single file (or common code path) where the HTML <head> section is defined — this is where embed.js and window.metabaseConfig will be injected (unless already present per Step 1c).
Search for:
<head> or <!DOCTYPE or <html in template/view filesinclude('head'), <%- include, {% extends, {% block, layout, base.html, _layout, application.htmlres.send(...)), identify where the <head> content is generatedGrep for METABASE_ and MB_ prefixed variables. Record every Metabase-related variable name and where it is read.
Compile all findings into:
Backend: {language}, {framework}, {template engine}
Metabase config:
- Site URL variable: {name} (read at {file}:{line})
- Secret key variable: {name} (read at {file}:{line})
- Other variables: ...
Layout/head file: {path}:{line range}
Static embeds found: {count}
- {file}:{line} — {brief description} (dashboard/question, ID: {id})
- ...
JWT signing: {file}:{line} — {library used}
JWT payload: resource type={dashboard|question}, params={list or "none"}
iframeResizer: {present|not present}
Existing modular embedding: {embed.js: yes/no, metabaseConfig: yes/no}
Use the documentation fetched in Step 1a as the authoritative reference for web component attributes, window.metabaseConfig options, and guest embedding behavior. The hardcoded tables below are fallbacks — if the docs describe additional attributes or different behavior for the target version, prefer the docs.
For each static embed found in Step 1:
Extract from the iframe src attribute:
dashboard or question (from the /embed/{type}/ path)resource field (e.g., resource: { dashboard: 10 })params in the JWT payload (e.g., params: { category: ["Gadget"] })# (e.g., #titled=true&bordered=false)iFrameResize() is called on this iframe| Static embed URL pattern | Modular Web Component | Required Attribute |
|---|---|---|
| /embed/dashboard/{JWT} | <metabase-dashboard> | token="{JWT}" |
| /embed/question/{JWT} | <metabase-question> | token="{JWT}" |
The token attribute receives the same signed JWT that was previously baked into the iframe URL. The backend signing code stays the same — only the delivery mechanism changes.
If the token was built dynamically in a template (e.g., src="<%= metabaseUrl %>/embed/dashboard/<%= token %>"), extract the token variable and pass it as the token attribute (e.g., token="<%= token %>").
Parameters that map to web component attributes:
| Static embedding hash params | Guest embeds equivalent |
|---|---|
| titled=true/false | with-title="true/false" on the component |
| bordered=true/false | No direct equivalent — drop (web components have no border chrome) |
| refresh=N | No direct equivalent — drop (handled by Metabase instance config) |
| theme=night | Use window.metabaseConfig.theme instead (if supported by version) |
Locked parameters (in JWT params field) — no change needed. They remain in the JWT and continue to work the same way. The signed token already contains them.
Editable parameters — if the static embed allowed users to interact with filters, these can now be set as defaults via the initial-parameters attribute:
<metabase-dashboard
token="{JWT}"
initial-parameters='{"category":["Doohickey","Gizmo"]}'
></metabase-dashboard>
initial-parameters sets default filter values that the user can change. This is a new capability not available in static iframe embedding.
For each static embed, output:
embed #{n}: {file}:{line}
Old: {iframe HTML or signing + iframe code}
Content type: {dashboard|question}
Token variable: {template expression for the signed JWT}
Locked params: {in JWT — no change needed}
Hash params: {list or "none"}
Dropped params: {list}
Mapped attributes: {list}
New: {exact replacement web component HTML}
Create a complete file-by-file change plan covering all areas below. Every change should be specified with the target file, the old code, and the new code.
Skip this step if Step 1c found an existing window.metabaseConfig assignment. If it exists but is missing isGuest: true, add that field to the existing config instead of creating a new one.
<head>, before the embed.js script tag (the config must be set before embed.js loads)<script>
window.metabaseConfig = {
isGuest: true,
instanceUrl: "{METABASE_SITE_URL}",
};
</script>
isGuest: true is required — it tells embed.js to use guest (signed token) mode instead of SSO mode.instanceUrl should be rendered dynamically using the project's template expression syntax.locale parameter was found in any static embed hash, add locale: "{code}" to the config object.window.metabaseConfig options supported by the target version (e.g., theme, font).window.metabaseConfig should be set exactly once.Skip this step if Step 1c found an existing embed.js script tag.
<head>, after the window.metabaseConfig script (embed.js reads the config on load)<script defer src="{METABASE_SITE_URL}/app/embed.js"></script>
{METABASE_SITE_URL} should be rendered dynamically using the project's existing template expression syntax.The backend already has JWT signing code that produces the token. Currently it builds a full iframe URL (/embed/dashboard/{token}#params). The signing logic stays — but how the token reaches the frontend changes:
<iframe src="{url}"><metabase-dashboard token="{token}">For each signing location found in Step 1d:
jwt.sign(payload, METABASE_SECRET_KEY)) unchanged{baseUrl}/embed/dashboard/ and appended hash paramsIf the signing happens inline in the template handler (not in a shared function), the change is local to that handler.
For EACH iframe from Step 2e's Migration Mapping Table:
token="{token_variable}" where {token_variable} is the template expression for the signed JWT<div> needed:
width/height HTML attributes or inline style, apply them directly to the web component (e.g., <metabase-dashboard token="..." style="width:800px;height:600px">)iframeResizer for auto-height, drop it — web components handle their own sizingiframeResizer calls associated with this iframeAfter replacing iframes, identify and remove:
/embed/dashboard/{token}#params or /embed/question/{token}#params stringsiframeResizer.js script tag and any iFrameResize() callsconst mods = "titled=true&bordered=false")Do not remove:
jwt.sign(payload, METABASE_SECRET_KEY)) — still usedMETABASE_SECRET_KEY env var — still usedList these as part of the plan — they will be included in the final summary:
http://localhost:9090). This is new — static iframe embedding did not require CORS configuration.Apply all changes from Step 3 in this order:
window.metabaseConfig assignment and embed.js script tag to the layout/head file (Step 3a + 3b, config before embed.js)Constraints:
old_string / new_string for every changeMETABASE_SECRET_KEY — it is still used for signingPerform all of these checks. Each check should have an explicit pass/fail result.
Use Grep to search for /embed/dashboard/ and /embed/question/ across all project files (excluding node_modules, .git, lockfiles).
Pass criteria: zero static embed URL constructions found (the pattern may still appear in comments — verify these are not live code).
Use Grep to search for /app/embed.js across all project files (excluding node_modules, .git).
Pass criteria: exactly ONE occurrence in the layout/head file.
Use Grep to search for window.metabaseConfig across all project files (excluding node_modules, .git).
Pass criteria: exactly ONE occurrence with isGuest: true.
Read the JWT signing file(s). Verify:
jwt.sign (or equivalent) call still existsMETABASE_SECRET_KEY is still read from environmentresource and params fieldsPass criteria: signing logic intact.
Use Grep to search for iframeResizer and iFrameResize across all project files.
Pass criteria: zero references remain (or only in unrelated code).
Read each modified file and verify:
token attribute with correct template expressionPass criteria: all checks pass.
If ANY check fails:
Organize the final output into these sections:
| File | Old | New |
|---|---|---|
| views/analytics.ejs | <iframe src="/embed/dashboard/{token}#titled=true"> | <metabase-dashboard token="{token}" with-title="true"> |
METABASE_SECRET_KEY, locked parameters in JWT params fieldinitial-parameters attribute for editable filter defaultswith-downloads attribute for enabling downloads (Pro/Enterprise)bordered appearance option is no longer available — web components render without a framerefresh=N) is no longer controlled per embed — configure it in Metabase instance settings insteadDoc fetching:
llms-embedding-full.txt returns 404, verify the Metabase version number and retry. If still failing, mark Step 1 ❌ blocked.Validation:
tools
Drive a Metabase instance from the terminal via the `mb` CLI. Authenticate with named profiles; inspect databases (list, get, full metadata rollup, schemas, tables in a schema) and trigger manual schema sync / field-values rescan; inspect tables, fields; list/get/create/update/archive cards (questions, models, metrics) and run them as JSON/CSV/XLSX; list/get/create/update dashboards and patch dashcards; list/get/create collections and traverse the hierarchy by id, entity_id, or "root"/"trash" (with items and recursive tree); list/get/create/update/archive native query snippets, segments, and measures; author/update/run transforms and schedule transform-jobs; read/update settings; search content (cards, dashboards, collections, transforms, metrics); manage Enterprise workspaces; git-sync to/from a git remote (status, dirty, import, export, branches, stash, add/remove a collection from sync). Use whenever the user wants to interact with a Metabase from the terminal — "log into metabase", "what profiles do I have", "list cards", "run card 42 as CSV", "create a transform", "list dashboards", "move a dashcard", "list collections", "what's in collection 4", "show the collection tree", "list snippets", "create a segment", "archive a measure", "search metabase for X", "spin up a workspace", "import the latest changes", "add a directory to git sync", "set a setting", "what schemas are in this database", "trigger a sync", "rescan field values", or anything hitting `mb <verb>`.
development
Runs the Metabase semantic checker against a tree of Representation Format YAML files to verify that all references resolve — cross-entity references (collection_id, dashboard_id, parent_id, parameter source cards, snippet references, transform tags, etc.) and references to columns inside MBQL and native queries. Slow (≥1 min per run). Only use when the user explicitly asks to verify entity references or column references in MBQL/SQL queries; in most cases this runs as a CI step, not locally. Requires database metadata on disk (by default `.metadata/table_metadata.json`).
development
Understands the Metabase Database Metadata Format — a YAML-based on-disk representation of databases, tables, and fields synced from a Metabase instance. Use when the user needs to read, edit, or understand metadata files produced by `@metabase/database-metadata`, or when reasoning about a project's schema (columns, types, FK relationships) through the `.metadata/databases` folder.
development
Understands the Metabase Representation Format — a YAML-based serialization format for Metabase content (collections, cards, dashboards, documents, segments, measures, snippets, transforms). Use when the user needs to create, edit, understand, or validate Metabase representation YAML files, or when working with Metabase serialization/deserialization (serdes). Covers entity schemas, MBQL and native queries, visualization settings, parameters, and folder structure.