# Morph Embed — integration guide for AI coding agents
You are integrating the `morph-embed` SDK into a web application. Morph Embed adds an
AI prompt bar to the app: end-users type what they want ("make this dark", "add a box
showing lost deals in the last 15 days") and the page reshapes in place or renders a
real-data widget. It is additive — it must never block or break the host app.
Docs (human): https://usemorph.xyz/docs
Package: https://www.npmjs.com/package/morph-embed
Keys: https://usemorph.xyz/dashboard
## Definition of done
1. The SDK loads and `window.Morph.init` is a function.
2. `Morph.init({ key })` is called EXACTLY ONCE with a valid publishable key.
3. The app's real data/actions are registered as `sources`/`actions` so widgets show
live values (not placeholders).
Do the minimal thing first (script + init with just a key → reshape works), verify,
then add sources/actions.
## Install
- Static/SSR HTML — script tag (adds `window.Morph`); pin a major:
- Bundled app — `npm install morph-embed`, then `import { init } from 'morph-embed'`
(ESM default export). `morph-embed/iife` is the browser-global build.
## The key (publishable — NOT a secret)
- Format `pk_live_…`. Designed to appear in client source. In a bundler, expose it as
a PUBLIC env var (NEXT_PUBLIC_MORPH_KEY, VITE_MORPH_KEY, …). Do NOT treat it as a
server secret or proxy it.
- Get one at https://usemorph.xyz/dashboard and register the app's exact origins
(scheme://host[:port]; www and apex are distinct). If you can't get one, leave
`key: 'pk_live_REPLACE_ME'` and tell the human to create a key + register the origin.
- Unregistered origin → HTTP 403 (fix = register origin, not code). Bad key → 401.
## Config contract (only `key` is required)
Morph.init({
key: string, // pk_live_… (publishable)
sources?: {
[name]: {
fetch: (query) => Promise, // return the app's records; runs in-page under app auth
schema: { [field]: 'string'|'number'|'date'|'boolean' }, // declare EVERY queryable field
description: string, // one line; include enum values
}
},
actions?: {
[name]: {
run: (args) => Promise, // your callback; args validated first
argsSchema: { [arg]: 'string'|'number' },
description: string,
confirm?: boolean, // default true; keep true for destructive actions
}
},
storage?: { // omit → localStorage (per-browser)
get: (key) => value|Promise,
set: (key, value) => void|Promise,
remove: (key) => void|Promise,
},
trigger?: 'alt+m'|'ctrl+shift+k'|'cmd+k'|'none', // default 'alt+m'
launcher?: false | { position?: 'bottom-right'|'bottom-left', accent?: string },
backend?: string, // override API endpoint (rarely)
})
After init: `window.Morph.open()` opens the bar (pair with trigger:'none' + your button);
`window.Morph.bar` has .show()/.hide()/.toggle().
## Wire the app's data as sources (high-value step)
For each dataset the app already has, register a source whose `fetch` calls the app's
EXISTING API/state and returns an array of plain objects, and whose `schema` declares
every field a widget might reference.
sources: {
orders: {
fetch: async () => (await api.get('/orders')).data,
schema: { status: 'string', total: 'number', createdAt: 'date' },
description: 'All orders (status: paid|refunded|pending)',
},
}
Rules: only register sources backed by real fetchable data. Never invent a source,
field, or number. Every schema field must exist on the returned objects. Any field a
user might ask about must be in the schema (missing field = hard error, not a zero).
Dates may be ISO strings, epoch ms, or Date objects.
## Wire actions (optional — only for real mutations)
actions: {
createTask: {
run: async (args) => api.post('/tasks', args),
argsSchema: { note: 'string' }, // string|number only
description: 'Create a follow-up task',
confirm: true,
},
}
The generated button is inert until a real user clicks it, and (confirm:true) shows a
confirm dialog first. Args are schema-validated; unknown/off-type args dropped.
## Storage (optional)
Default localStorage (per-browser). To sync a user's features across devices, pass a
{get,set,remove} adapter backed by the app's persistence, keyed by the current user.
JSON-serializable values; async allowed; must not throw.
## Framework recipes
### Plain HTML
### React (init once, guard double-invoke)
import { useEffect } from 'react';
import { init } from 'morph-embed';
function MorphProvider() {
useEffect(() => {
if (window.__morphInit) return; // idempotent guard (StrictMode/HMR)
window.__morphInit = true;
init({
key: process.env.NEXT_PUBLIC_MORPH_KEY,
sources: { deals: { fetch: async () => api.deals(), schema: { stage: 'string', amount: 'number', closedAt: 'date' }, description: 'CRM deals (stage: won|lost|open)' } },
});
}, []);
return null;
}
Render once, high in the tree, in a client component.
### Next.js (App Router)
1. Make a 'use client' component identical to the React recipe; render it once in app/layout.tsx.
2. .env.local: NEXT_PUBLIC_MORPH_KEY=pk_live_… (public by design).
3. Do NOT import morph-embed in a Server Component — it uses `window`. Keep it in a
'use client' module inside useEffect.
## Rules — DO / DON'T
DO: init exactly once; guard re-init on HMR/StrictMode.
DON'T: init per render or per route change.
DO: put the pk_live_ key in a PUBLIC env var / client source.
DON'T: hide it as a server secret or proxy it (it's publishable).
DO: register only sources backed by real fetchable data.
DON'T: invent sources, fields, or numbers.
DO: declare every queryable field in schema.
DON'T: omit fields users might ask about.
DO: keep confirm:true for destructive actions.
DON'T: set confirm:false on irreversible mutations.
DO: let Morph load async and fail open.
DON'T: block the app's render on Morph or throw on 4xx.
DO: load morph-embed only client-side.
DON'T: import it in a Server Component / SSR path.
DO: tell the human to register the app's origin in the dashboard.
DON'T: assume a 403 is a code bug — it's an origin-allowlist gap.
## Verification checklist (browser, at a registered origin)
- [ ] typeof window.Morph.init === 'function'
- [ ] init ran once (no duplicate launcher/bar in the DOM)
- [ ] Alt+M or the ✦ launcher opens the prompt bar
- [ ] A reshape prompt ("make this page dark") applies + shows an Undo toast
- [ ] If sources registered: a data prompt renders a REAL number, not a placeholder
- [ ] Reloading the page reapplies the saved edit
- [ ] No console errors; Morph failing to load does not break the page
If a widget shows a placeholder instead of a number: the source isn't registered or a
query field isn't in its schema. Fix the source/schema — do NOT hardcode a value.
## How it works (context)
The model never returns code — only a fixed declarative JSON DSL that the SDK validates
and sanitizes in the browser before touching the DOM. Your data never leaves the page
(only source names + field types are sent to the model; it never sees or emits records
or numbers). You don't author the DSL; the op grammar is documented for reference at
https://usemorph.xyz/docs#dsl. Query language for renderData: filter (equality/IN),
dateField + since/until (durations '15d'/'24h'/'30m' — days/hours/minutes, no weeks — or
ISO date), groupBy, aggregate {fn: sum|count|avg|min|max, field}, limit. Charts: stat,
bar, line. Field types: string|number|date|boolean. Action arg types: string|number.
## Privacy note (vendor-keyed requests)
For requests authenticated by a vendor's pk_live_ key, the backend retains product
signals for that vendor: the structured shape of the generated change (op type, source
name, field names, never field values or records) plus a small sample of truncated
prompt texts, aggregated per vendor to power the dashboard's Signals panel. No
end-user identifier, IP address, or page content is stored. Disclosing this to your own
end users is your responsibility as the vendor. Full statement:
https://usemorph.xyz/privacy#embed-signals