Envelope SDK Reference
This reference documents the SDK inside built apps. It is generated from the exact documentation the AI builder codes against, so it always matches what the builder produces. Because of that, it is written from the builder's point of view: "your app" means the app being built.
The SDK is available globally inside built apps, with no imports needed. The Pre-Built Helpers below are available in every app; a module's SDK is only available when that module is enabled on the app (see Modules).
Pre-Built Helpers
The following helper functions, hooks, and components are available globally — no need to define them yourself. Use these instead of writing boilerplate.
Sound Effects
Apps run in a sandboxed iframe. External audio URLs are blocked by CSP. Use these pre-built helpers instead:
playTone(frequency, duration, { volume, type })— play a custom tone. Types:'sine','square','sawtooth','triangle'playChime()— pleasant two-note ascending chimeplayBeep()— short alert beepplaySuccess()— rising success soundplayError()— descending error buzz
DO NOT use new Audio('https://...') — it will fail silently. Always use playChime(), playBeep(), etc. Sounds must be triggered by user interaction (button tap) on mobile.
Markdown
<Markdown>{text}</Markdown> — renders markdown text as styled HTML. Supports bold, italic, strikethrough, inline code, code blocks, headers (h1-h6), links, lists, blockquotes, and horizontal rules. Citation references like [1] from Perplexity responses are automatically cleaned up.
Use this for any app that displays user-written content (notes, journals, articles, AI responses, etc.):
<Markdown>{note.content}</Markdown>
<Markdown>{"# Hello\n\nThis is **bold** and *italic*."}</Markdown>
LaTeX / Math
<LaTeX math="E = mc^2" /> — renders a LaTeX math expression using KaTeX. Available globally.
Props:
math(string) — the LaTeX expression to renderdisplay(boolean, defaulttrue) —truefor block/display mode,falsefor inline modeclassName— optional CSS class
Use this for any app that shows mathematical formulas, equations, or scientific notation:
<LaTeX math="\\sum_{i=1}^{n} x_i" />
<LaTeX math="\\alpha + \\beta" display={false} />
You can also use renderLatex(str) to get an HTML string for use with dangerouslySetInnerHTML.
Date & Time
formatDate(date, style)— format a date. Styles:"short"(Jan 5),"medium"(Jan 5, 2025),"long"(January 5, 2025),"numeric"(1/5/2025)formatTime(date, style)— format time. Styles:"short"(2:30 PM),"long"(2:30:45 PM)formatDateTime(date, style)— combined date + timetimeAgo(date)— relative time ("5 minutes ago", "2 hours ago", "3 days ago")
Pass a Date object or any value accepted by new Date() (ISO string, timestamp, etc.):
<span>{timeAgo(item.createdAt)}</span>
<span>{formatDate(new Date(), "medium")}</span>
Streak calculation — for habit/fitness trackers, a streak should NOT reset on today if the user hasn't logged yet. Start counting from yesterday (or today if already logged):
function calcStreak(dates) {
const dateSet = new Set(dates.map(d => new Date(d).toDateString()));
let cursor = dateSet.has(new Date().toDateString()) ? new Date() : new Date(Date.now() - 86400000);
let streak = 0;
while (dateSet.has(cursor.toDateString())) {
streak++;
cursor = new Date(cursor.getTime() - 86400000);
}
return streak;
}
React Hooks
useLocalStorage(key, initialValue)— likeuseStatebut persists to IndexedDB on the device. Returns[value, setValue]. Handles JSON parsing and errors automatically.useDebounce(value, delay)— returns a debounced version ofvalue. Default delay: 300ms. Great for search inputs.useInterval(callback, delay)— safesetIntervalwith automatic cleanup. Passnullas delay to pause. Use for periodic UI updates (animations, polling). For countdowns and timers, preferuseTimer()instead — it survives app backgrounding.useTimer(durationMs, options?)— background-resilient countdown timer. UsesDate.now()internally so it keeps accurate time even when the app is minimized or backgrounded on mobile. Returns{ remaining, status, isRunning, isPaused, isComplete, start, pause, resume, reset }. Options:{ tickInterval: 1000, onComplete: fn, onTick: fn, autoStart: true }. Always use this for Pomodoro timers, cooking timers, countdowns, and any time-based features.useOnlineStatus()— returnstruewhen the device is online,falsewhen offline. Re-renders automatically on connectivity changes. Use to show offline banners, disable submit buttons, or queue operations.
// Pomodoro example
const timer = useTimer(25 * 60 * 1000, {
onComplete: () => { playChime(); setPhase("break"); },
});
// timer.remaining = ms left, timer.pause(), timer.resume(), timer.reset()
// timer.start(5 * 60 * 1000) to start a new countdown
// Dynamic duration (presets, slider): pass targetDuration to useTimer; when it changes, call timer.reset() to sync display
const [targetDuration, setTargetDuration] = useLocalStorage('timer-duration', 60000);
const timer = useTimer(targetDuration, { onComplete: () => playBeep(), autoStart: false });
useEffect(() => { if (!timer.isRunning) timer.reset(); }, [targetDuration]);
Math
calculate(expr)— safely evaluate a math expression string. Returns a number orNaNon invalid input. Supports+,-,*,/,%(modulo), parentheses, unary minus, and decimals.
ALWAYS use calculate() for calculators and any math evaluation. Do NOT use eval() or new Function() — they are blocked by CSP:
calculate("2 + 3 * (4 - 1)") // 11
calculate("(10.5 + 3.5) / 2") // 7
calculate("100 % 7") // 2
calculate("invalid") // NaN
Calendar Export
These helpers let users export events to their phone's calendar via .ics file download or Google Calendar link. ALWAYS use these for trip planners, event apps, itinerary apps, and booking apps.
Event object shape: { title: string, start: Date|string, end?: Date|string, description?: string, location?: string, allDay?: boolean }
createICSEvent(event)— returns an .ics string for a single eventcreateICSCalendar(events)— returns an .ics string with multiple events in one filedownloadICS(icsContent, filename?)— triggers browser download of an .ics file (default filename: "event.ics")openInGoogleCalendar(event)— opens Google Calendar with the event pre-filledaddToCalendar(event)— convenience: creates and downloads a single-event .ics fileaddAllToCalendar(events, filename?)— convenience: creates and downloads a multi-event .ics file
// Single "Add to Calendar" button
<button onClick={() => addToCalendar({
title: "Beach Day",
start: new Date("2025-07-15T10:00:00"),
end: new Date("2025-07-15T16:00:00"),
location: "Santa Monica Beach",
description: "Bring sunscreen!"
})}>Add to Calendar</button>
// Export all events
<button onClick={() => addAllToCalendar(events, "trip-itinerary.ics")}>
Export All to Calendar
</button>
// Google Calendar link
<button onClick={() => openInGoogleCalendar(event)}>
Open in Google Calendar
</button>
Local Notifications
envelope.notify lets your app show device notifications — even when the app is backgrounded. No module toggle needed. Perfect for timers, reminders, and alerts.
Push vs. Local Notifications:
-
envelope.notify(always available) = local, one-shot countdown timers scheduled from now. Use for timers, countdowns, and reminders triggered at app-open time.schedule(delayMs, ...)fires once afterdelayMsmilliseconds. Neither API supports recurring cron-style alarms. -
envelope.push(Push Notifications module) = server-sent notifications to the device. Use for notifications triggered by events (new message, assignment). For daily reminders (e.g. "today's workout"), callenvelope.notify.schedule()on each app open to re-arm the next reminder. -
envelope.notify.requestPermission()— asks the user for notification permission. Returns a Promise resolving to"granted","denied", or"default". -
envelope.notify.show(title, body, options?)— show a notification immediately. Options:{ tag }. Auto-requests permission if not yet granted. Returns a Promise. -
envelope.notify.schedule(delayMs, title, body, options?)— schedule a notification to fire afterdelayMsmilliseconds, even if the app is closed. Options:{ tag, id }. Returns a Promise resolving to{ scheduleId, reliable }. Whenreliableis true, the notification is server-scheduled and will fire even if the tab is closed. When false, it uses a best-effort browser timer. -
envelope.notify.cancel(scheduleId)— cancel a previously scheduled notification.
Timer + notification pattern (Pomodoro, cooking timers, etc.):
const [scheduleId, setScheduleId] = useState(null);
const timer = useTimer(25 * 60 * 1000, {
onComplete: () => { playChime(); setPhase("break"); },
autoStart: false,
});
// Schedule background notification when timer starts, cancel when it stops
useEffect(() => {
if (timer.isRunning) {
envelope.notify.schedule(timer.remaining, "Time's up!", "Your session is complete.")
.then((result) => setScheduleId(result.scheduleId));
} else if (scheduleId) {
envelope.notify.cancel(scheduleId);
setScheduleId(null);
}
}, [timer.status]);
Online / Offline Status
Detect whether the device has network connectivity. No module toggle needed.
envelope.isOnline— boolean,truewhen online, updated automatically.envelope.onOnline(callback)— called when connectivity is restored.envelope.onOffline(callback)— called when connectivity is lost.useOnlineStatus()— React hook returningtrue/false. Re-renders automatically on connectivity change.
const isOnline = useOnlineStatus();
// Show an offline banner
{!isOnline && <div className="bg-yellow-500/10 text-yellow-400 text-center p-2 text-sm">You're offline</div>}
Utility Functions
debounce(fn, delay)— returns a debounced version of a functionthrottle(fn, limit)— returns a throttled version of a function
Envelope Database SDK (Key-Value Store)
Your app runs inside Envelope, which provides a cloud-backed key-value database. Use this instead of localStorage when you need data to persist across devices and sessions. Data stored with this SDK is saved to the cloud. Users can access their data from any device.
How It Works
Your app sends read/write requests via window.parent.postMessage(). Envelope proxies these to a server-side database and sends back the result. Each app has its own isolated storage; apps cannot access each other's data.
Data Scoping
The database supports two scopes:
"app"scope (default): Data is shared across all users of the app. Good for leaderboards, global settings, shared content."user"scope: Data is private to each authenticated user. Good for personal preferences, saved progress, user profiles. Requires Envelope Auth to be enabled on the app.
When using scope: "user", each user gets their own isolated namespace; User A cannot see or modify User B's data, even if they use the same keys.
Using the Database SDK
The envelope.db object is available globally; do NOT define your own helper. Call it directly:
Usage Example: Per-User Data (requires Auth)
When Envelope Auth is enabled, use { scope: 'user' } for data that belongs to each individual user:
// Save the current user's high score (private to them)
await envelope.db.set('highscore', 1500, { scope: 'user' });
// Get the current user's high score
const score = await envelope.db.get('highscore', { scope: 'user' });
// List the current user's saved items
const keys = await envelope.db.list('saved:', { scope: 'user' });
Usage Example: Shared App Data
Use default scope (or { scope: 'app' }) for data shared across all users:
// Global leaderboard visible to everyone
const leaderboard = await envelope.db.get('leaderboard');
// Update shared settings
await envelope.db.set('leaderboard', topScores);
Usage Example: Combining Both Scopes
// Each user saves their own score privately
await envelope.db.set('score', myScore, { scope: 'user' });
// The app maintains a shared leaderboard
const leaderboard = await envelope.db.get('leaderboard') || [];
leaderboard.push({ name: username, score: myScore });
leaderboard.sort((a, b) => b.score - a.score);
await envelope.db.set('leaderboard', leaderboard.slice(0, 10));
API Reference
envelope.db.get(key, options?): Returns the stored value, ornullif key doesn't existenvelope.db.set(key, value, options?): Stores any JSON-serializable value (string, number, object, array, boolean, null)envelope.db.delete(key, options?): Removes a keyenvelope.db.list(prefix?, options?): Returns an array of key names, optionally filtered by prefix (e.g.'saved:'returns all keys starting withsaved:)
Options: { scope: 'app' | 'user' }; defaults to 'app' if omitted.
Error Handling (REQUIRED)
All envelope.db calls can fail (network errors, rate limits, storage limits). Always wrap in try/catch to prevent unhandled promise rejections that crash the app. Use a safe wrapper pattern:
const safeDbGet = async (key, opts) => {
try { return await envelope.db.get(key, opts); }
catch (e) { console.warn("DB get failed:", e); return null; }
};
const safeDbSet = async (key, val, opts) => {
try { await envelope.db.set(key, val, opts); }
catch (e) { console.warn("DB set failed:", e); }
};
Define these helpers once and use them everywhere instead of calling envelope.db directly. This prevents "Database request failed" crashes.
Performance: Polling & Efficiency (IMPORTANT)
The SDK caches reads for 2 seconds and deduplicates inflight requests automatically. However, you MUST still follow these rules:
- Polling interval: If you poll with
setInterval, use 15–30 seconds minimum. Never poll faster than every 10 seconds. 5-second intervals will hit the rate limit. - Batch reads into one function: Load all keys in a single
loadDatafunction, don't scatter individualenvelope.db.get()calls across multipleuseEffecthooks. - Don't retry on error: If a read fails, wait for the next poll cycle. Never retry immediately; this causes a 429 storm.
- Write only on user action: Call
envelope.db.set()in event handlers (onClick, onSubmit, onBlur), not in useEffect or render loops. - Load once on mount, poll sparingly: Load data once in a
useEffect([], [])on mount, then set a long polling interval only if real-time sync between users is needed. Most apps don't need polling at all.
// GOOD: Load once + long poll
useEffect(() => {
loadData();
const interval = setInterval(loadData, 20000); // 20s
return () => clearInterval(interval);
}, []);
// BAD: Tight poll that will hit rate limits
useEffect(() => {
const interval = setInterval(loadData, 5000); // Too fast!
return () => clearInterval(interval);
}, []);
Important Notes
- Cloud persistence: Data is stored on the server, not in the browser. It works across devices and survives browser clears, unlike localStorage.
- Use envelope.db instead of localStorage when the creator enables the Database module.
- User scope requires Auth:
{ scope: 'user' }only works when Envelope Auth is enabled on the app. Without Auth, use app scope. - User data isolation: With
scope: 'user', each user's data is completely private. Other users cannot read, write, or list another user's keys. - Key limits: Keys must be 256 characters or less. Up to 1,000 keys per app (across all scopes combined).
- Value limits: Each value can be up to 100KB. Total storage per app is 10MB (across all scopes combined).
- Rate limit: 120 requests per minute per app.
- JSON values: You can store any JSON type: strings, numbers, booleans, objects, arrays, null.
- Always show a loading state while data is being fetched on first load.
- The creator must enable "Database (KV Store)" in their Envelope dashboard for this to work.
Envelope Auth SDK (User Identity)
When the "Require Auth" module is enabled, every user who opens the app must sign in first. After sign-in, Envelope automatically sends the authenticated user's identity to your app. Use this to identify users, personalize data, and build collaborative/multi-user features.
How It Works
Envelope handles the sign-in UI; your app just reads the result. The user's identity is delivered automatically when the app loads.
Getting the Current User
Use the useEnvelopeAuth() React hook (available globally):
const { user } = useEnvelopeAuth();
// user = { id: "abc-123", email: "name@example.com" } or null if not yet loaded
if (user) {
console.log("Logged in as:", user.email);
}
Or use the imperative API:
const user = envelope.auth.getUser();
// user = { id: "abc-123", email: "name@example.com" } or null
API Reference
useEnvelopeAuth(): React hook returning{ user, token }. Re-renders when auth state changes.envelope.auth.getUser(): Returns the current user object ({ id, email }) ornull.envelope.auth.getToken(): Returns the current auth JWT token string ornull.envelope.auth.onAuthChange(callback): Registers a callback fired when the user signs in. If already signed in, fires immediately.
User Object Shape
{
id: string, // Unique user ID (UUID)
email: string // User's email address
}
Important: Use Real User Identity
NEVER generate fake/random user IDs (no Math.random(), no crypto.randomUUID(), no useLocalStorage("profile", { id: randomId })). When Auth is enabled, always use useEnvelopeAuth() to get the real authenticated user. Random IDs create duplicate "ghost" entries in the database on every page reload.
- Collaborative apps: Store each user's data keyed by their real
user.id, not a random ID - Member lists: The ONLY source of group members is
useEnvelopeAuth(): users who have signed in. Do NOT store members from random IDs. - User profiles: Derive the display name from
user.email(e.g., the part before @)
Common Patterns
Identifying the user for shared data (collaborative apps):
const { user } = useEnvelopeAuth();
// Register this user in a shared member list
useEffect(() => {
if (!user) return;
const registerMember = async () => {
try {
const members = await envelope.db.get("members", { scope: "app" }) || {};
members[user.id] = {
email: user.email,
name: user.email.split("@")[0],
lastSeen: new Date().toISOString()
};
await envelope.db.set("members", members, { scope: "app" });
} catch (e) {
console.warn("Failed to register member", e);
}
};
registerMember();
}, [user]);
Storing per-user data:
// Private data: only this user can access it
await envelope.db.set("preferences", { theme: "dark" }, { scope: "user" });
// Shared data: attribute it to the user
const entry = { text: "Hello!", author: user.email, createdAt: Date.now() };
Waiting for auth, with loading state (NEVER block forever):
const { user } = useEnvelopeAuth();
const [authReady, setAuthReady] = useState(false);
useEffect(() => {
// If user loads, we're ready. Also set a timeout so we never block forever.
if (user) { setAuthReady(true); return; }
const timer = setTimeout(() => setAuthReady(true), 3000);
return () => clearTimeout(timer);
}, [user]);
if (!authReady) {
return <div className="h-screen flex items-center justify-center"><Loader2 className="size-6 animate-spin" /></div>;
}
// user may be null if auth timed out; handle gracefully
return <div>Welcome, {user ? user.email.split("@")[0] : "Guest"}!</div>;
IMPORTANT: Never use if (!user) return <Loading /> without a timeout; this blocks the app forever if auth takes too long or the preview environment doesn't have auth configured.
Envelope External Data SDK
Your app runs inside Envelope, which provides envelope.fetch() and envelope.proxyImageUrl() for fetching data from external APIs and displaying external images. The sandbox blocks native fetch() and external img src; use these APIs instead.
How It Works
- envelope.fetch(url, options?): Proxies HTTP requests through Envelope's server. Returns parsed JSON (or text) directly. Use for any external REST API.
- envelope.proxyImageUrl(url): Returns a same-origin URL that proxies the image. Use in
<img src={...} />for images from external CDNs.
Using envelope.fetch()
const data = await envelope.fetch('https://api.example.com/items/42');
// data is the parsed JSON response
// POST with body:
const result = await envelope.fetch('https://api.example.com/submit', {
method: 'POST',
body: { name: 'test', value: 42 }
});
Options:
method: 'GET' (default) or 'POST'headers: optional object of request headers. Only these are allowed: Accept, Accept-Language, Accept-Encoding, Content-Type, Authorization, X-API-Key, X-Auth-Token, Api-Key. Other headers are ignored.body: for POST requests, a string or JSON-serializable object to send as the request body (max 2MB)
Using envelope.proxyImageUrl()
<img src={envelope.proxyImageUrl(item.imageUrl)} alt={item.name} />
For images from allowlisted CDNs (raw.githubusercontent.com, openweathermap.org, etc.), you can use the URL directly in img src; the CSP permits them. For other domains, use envelope.proxyImageUrl().
Usage Example: Data Fetching
const [item, setItem] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
async function loadItem(id) {
setLoading(true); setError(null);
try {
const data = await envelope.fetch(`https://api.example.com/items/${id}`);
setItem(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
Usage Example: Weather with Icons
const data = await envelope.fetch(`https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${apiKey}`);
const iconUrl = `https://openweathermap.org/img/wn/${data.weather[0].icon}@2x.png`;
// openweathermap.org is allowlisted; can use directly or via proxy
<img src={envelope.proxyImageUrl(iconUrl)} alt="Weather" />
Important Notes
- Rate limit: 30 fetch requests per minute, 60 image proxy requests per minute per app
- Always show a loading state while fetching
- Always handle errors (try/catch, show error message to user)
- Cache responses with useLocalStorage when appropriate to reduce API calls
- Only HTTPS URLs are allowed; private IPs and localhost are blocked
- Image proxy has a domain allowlist; only known-safe CDNs are supported for images
Resilient batch fetching
When loading multiple items (e.g. a list of IDs for a quiz or collection), do not use a single Promise.all over all requests; one failed request will reject the whole batch. Instead:
- Use
Promise.allSettledand filter to fulfilled results, or - Fetch in a loop with try/catch and skip or retry failed items
- If you need a minimum number of items, retry failed IDs or use a fallback so the user still gets a working quiz/list
Caching Pattern
const [data, setData] = useLocalStorage('api-cache', null);
useEffect(function() {
if (data) return; // use cache
envelope.fetch(url).then(setData).catch(console.error);
}, []);
Envelope AI Chatbot SDK
Your app runs inside Envelope, which provides access to a streaming AI chatbot powered by Google Gemini. Use this for conversational AI features: chat assistants, tutors, support bots, or any multi-turn AI interaction.
How It Works
Your app sends messages via window.parent.postMessage(). Envelope proxies the request to Gemini and streams the response back as chunks via postMessage. Your app manages its own conversation history and sends it with each request.
Using the Chatbot SDK
The useChatbot() hook is available globally; do NOT define your own. Use it directly:
const { messages, streaming, partial, error, send } = useChatbot();
// Send a message (manages history automatically)
send('Explain photosynthesis', { systemPrompt: 'You are a biology tutor.' });
The hook returns:
messages: array of{ role: 'user'|'assistant', content: string }streaming: boolean, true while AI is generating a responsepartial: the partial response text being streamed (show this with a blinking cursor)error: string or null. Set when the chatbot request fails (e.g., rate limit, payload too large). Cleared on the next successful response or newsend()call. Always checkerrorand show a user-friendly message (e.g., an Alert or toast) so failures are never silent.send(text, options?): send a message. Returns nothing (void); it is fire-and-forget. The AI response arrives asynchronously viamessagesstate (not as a return value). Do NOTawaitit or capture its return. Options:{ model: 'flash'|'pro', systemPrompt: string, image: string }
IMPORTANT: send() does NOT return the AI response:
send() fires a request and immediately returns undefined. The response arrives asynchronously through the hook's reactive state:
streamingbecomestruepartialaccumulates streamed chunks- When complete, a new
{ role: 'assistant', content }entry is appended tomessages streamingbecomesfalse
To use the response, watch messages in a useEffect. Never write const response = await send(...); it is always undefined.
Usage Example
Use h-screen flex flex-col so the input stays pinned at the bottom. If the app has multiple sections, put nav in a compact top header with a Sheet, never a bottom tab bar. If the chatbot is one tab in a multi-tab app with a bottom nav, use pb-[52px] on the chat tab wrapper and make the input a normal shrink-0 flex child (not absolute/fixed).
function App() {
const { messages, streaming, partial, error, send } = useChatbot();
const [input, setInput] = useState('');
const scrollRef = useRef(null);
function handleSend() {
if (!input.trim() || streaming) return;
send(input.trim());
setInput('');
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, partial]);
return (
<div className="h-screen bg-background text-foreground flex flex-col">
<header className="border-b px-4 py-2 flex items-center shrink-0">
<Bot className="size-5 text-primary mr-2" />
<h1 className="text-lg font-semibold">AI Chat</h1>
</header>
<div className="flex-1 overflow-y-auto p-4" ref={scrollRef}>
{messages.length === 0 && !streaming && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
<Sparkles className="size-8 mb-2 text-primary" />
<p>Send a message to start chatting</p>
</div>
)}
<div className="space-y-3">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{msg.role === 'assistant' && (
<Avatar className="size-7 mr-2 shrink-0">
<AvatarFallback className="bg-primary text-primary-foreground text-xs">AI</AvatarFallback>
</Avatar>
)}
<div className={`rounded-2xl px-3 py-2 max-w-[80%] text-sm ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground'
}`}>
{msg.role === 'assistant' ? <Markdown>{msg.content}</Markdown> : msg.content}
</div>
</div>
))}
{streaming && partial && (
<div className="flex justify-start">
<Avatar className="size-7 mr-2 shrink-0">
<AvatarFallback className="bg-primary text-primary-foreground text-xs">AI</AvatarFallback>
</Avatar>
<div className="rounded-2xl px-3 py-2 max-w-[80%] text-sm bg-secondary text-secondary-foreground">
<Markdown>{partial}</Markdown><span className="animate-pulse">|</span>
</div>
</div>
)}
{streaming && !partial && (
<div className="flex justify-start">
<Avatar className="size-7 mr-2 shrink-0">
<AvatarFallback className="bg-primary text-primary-foreground text-xs">AI</AvatarFallback>
</Avatar>
<div className="rounded-2xl px-3 py-2 bg-secondary">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
{error && (
<Alert variant="destructive" className="mt-2">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
</div>
<div className="border-t p-3 flex gap-2 shrink-0">
<Textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={streaming}
className="flex-1 min-h-[44px] max-h-[120px] resize-none"
rows={1}
/>
<Button size="icon" onClick={handleSend} disabled={streaming || !input.trim()}>
<Send className="size-4" />
</Button>
</div>
</div>
);
}
Model Options
- flash (default): Fast and cheap; good for most conversational use cases
- pro: Stronger reasoning; use for complex or analytical conversations
Sending Images
You can attach an image to any message for vision/multimodal analysis. Pass a data URL or a persisted URL from the Images SDK:
// Analyze a user-uploaded photo
const { result } = useImagePicker();
send('What do you see in this image?', { image: result.url || result.dataUrl });
The AI can describe images, answer questions about them, read text in photos, etc. The image is only attached to the current message; it does not persist in the conversation history.
Rendering AI Responses
Always use the <Markdown> component to render AI assistant messages and streaming partial responses. AI responses often contain markdown formatting (bold, lists, code blocks, headers, etc.); rendering them as plain text loses all formatting and looks broken.
{msg.role === 'assistant' ? <Markdown>{msg.content}</Markdown> : msg.content}
The <Markdown> component is a pre-built global helper; no imports needed. See "Pre-Built Helpers" for details. User messages should stay as plain text since users type plain text.
Important Notes
- The creator must enable "AI Chatbot" in their Envelope dashboard for this to work
- Conversation history is managed in your app; Envelope does not store it
- Send the full conversation history with each request so the AI has context
- Rate limit: 30 requests per minute per app
- Responses stream in real-time as chunks for a snappy UX
- Always show a typing/streaming indicator while waiting
- Always handle errors gracefully (timeouts, rate limits); use the
errorfield fromuseChatbot() - The creator may set a custom system prompt via the dashboard; your app can also pass an additional
systemPromptoption to add context (max 2000 characters; keep prompts concise; move static instructions to the app-level system prompt in Configure)
Use Cases
- Chat assistants: General-purpose AI chat for any app
- Study tools: Interactive tutors that explain concepts
- Customer support: In-app FAQ or help bots
- Creative writing: Story generators, brainstorming partners
- Coding helpers: Explain code, suggest solutions
- Health & fitness: Personalized coaching conversations
- Language learning: Conversation practice partners
Structured Output Patterns (Parsing AI Responses into UI)
If your app parses chatbot responses into structured UI (workout cards, recipe cards, study plans, etc.), you need tight control over the AI's output format. LLMs naturally produce varied prose, so rigid regex parsing will break. Remember: send() returns nothing; read responses from messages state via useEffect. See the "One-Shot AI Analysis" pattern below for a complete example.
Recommended: JSON in Fenced Blocks
The most reliable approach is to have the AI return JSON inside a fenced code block. JSON is unambiguous and trivial to parse; no regex needed:
send('Push day 30 min', {
systemPrompt: \`You are a workout generator. CRITICAL: Your response will be machine-parsed.
You may include a brief 1-sentence note BEFORE the JSON block if needed, but the JSON block is MANDATORY.
Respond with a JSON code block in this EXACT format:
\\\`\\\`\\\`json
{
"name": "Workout Name",
"exercises": [
{ "name": "Pushups", "sets": 3, "reps": "12", "rest": 60, "weight": "" },
{ "name": "Squats", "sets": 4, "reps": "10", "rest": 90, "weight": "20" }
]
}
\\\`\\\`\\\`
Rules:
- ALWAYS wrap the JSON in triple backtick fences with "json" tag
- Include ALL fields for every exercise, even if empty string
- "weight" should be a number string or empty string\`
});
JSON Parsing Helper
function parseStructuredResponse(text) {
// Extract JSON from fenced code block
const jsonMatch = text.match(/\`\`\`(?:json)?\s*([\s\S]*?)\`\`\`/);
if (jsonMatch) {
try { return JSON.parse(jsonMatch[1].trim()); } catch {}
}
// Fallback: try parsing the whole text as JSON
try { return JSON.parse(text.trim()); } catch {}
return null;
}
// Usage:
const parsed = parseStructuredResponse(msg.content);
if (parsed && parsed.exercises?.length > 0) {
// Render structured workout cards with Save/Start buttons
} else {
// Fallback: render as Markdown + manual create button
<div>
<Markdown>{msg.content}</Markdown>
<Button variant="outline" onClick={() => openManualBuilder()}>
Create Plan from Message
</Button>
</div>
}
One-Shot AI Analysis (Non-Chat Use Case)
When your app uses the chatbot for behind-the-scenes AI analysis (not a visible chat UI), e.g., analyzing text, scoring features, generating structured data, use this pattern. Remember: send() returns nothing. Watch messages to capture the response.
function App() {
const { messages, streaming, partial, error, send } = useChatbot();
const [input, setInput] = useState('');
const [result, setResult] = useState(null);
const processedCount = useRef(0);
// Watch for new assistant messages and parse the structured response
useEffect(() => {
if (messages.length > processedCount.current) {
const latest = messages[messages.length - 1];
if (latest.role === 'assistant') {
const parsed = parseStructuredResponse(latest.content);
if (parsed) setResult(parsed);
}
processedCount.current = messages.length;
}
}, [messages]);
function handleAnalyze() {
if (!input.trim() || streaming) return;
setResult(null);
send(input.trim(), {
systemPrompt: \`Analyze the input and respond with a JSON code block:
\\\`\\\`\\\`json
{ "score": 0, "summary": "...", "details": [...] }
\\\`\\\`\\\`\`
});
}
return (
<div className="min-h-screen bg-background text-foreground p-4">
<Textarea value={input} onChange={e => setInput(e.target.value)} placeholder="Enter text to analyze..." />
<Button onClick={handleAnalyze} disabled={streaming} className="mt-2 w-full">
{streaming ? <><Loader2 className="size-4 animate-spin mr-2" />Analyzing...</> : 'Analyze'}
</Button>
{error && <Alert variant="destructive" className="mt-2"><AlertDescription>{error}</AlertDescription></Alert>}
{result && (
<Card className="mt-4 p-4">
<p className="font-semibold">Score: {result.score}</p>
<p className="text-sm text-muted-foreground mt-1">{result.summary}</p>
</Card>
)}
</div>
);
}
Key points for one-shot analysis:
- Never write
const response = await send(...);send()returnsundefined - Use
useRefto track which messages you've already processed, avoiding re-processing on re-renders - Show a loading state using
streamingwhile the AI is generating - Always handle
error; show it to the user - Parse the response from the latest assistant message in
messages
Fallback: Line-Based Parsing
If you prefer a simpler text format (e.g., for shorter prompts), use pipe-delimited fields instead of free-form text. Pipes are more reliable than commas or colons for parsing:
// System prompt format: "- Exercise Name | 3 sets | 10 reps | 60s rest | 15 weight"
function parseLineFormat(text) {
const lines = text.split('\n').filter(l => l.trim().startsWith('-'));
return lines.map(line => {
const parts = line.replace(/^[-*]\s*/, '').split('|').map(p => p.trim());
if (parts.length < 4) return null;
return {
name: parts[0],
sets: parseInt(parts[1]) || 3,
reps: parts[2]?.replace(/\s*reps?/i, '') || "10",
rest: parseInt(parts[3]) || 60,
weight: parts[4] ? parts[4].replace(/\s*weight/i, '').trim() : ""
};
}).filter(Boolean);
}
Key Takeaways
- Prefer JSON in fenced blocks over regex line parsing; it handles complex data (nested objects, arrays, optional fields) reliably
- Always show a fallback: render as Markdown if parsing fails, with a "Create from Message" button. Never show a blank screen.
- System prompt is your best tool: include 1-2 complete examples of the expected JSON output
- Keep per-request system prompts under 2000 chars: move static instructions to the app-level system prompt in Configure
Envelope Web Search SDK (Perplexity)
Your app runs inside Envelope, which provides access to Perplexity AI for real-time web search. Use this ONLY when the app needs to fetch live information from the internet: current prices, weather, news, live scores, or up-to-date facts. For general AI conversation, tutoring, or Q&A that doesn't require live web data, use the AI Chatbot module instead.
How It Works
Your app sends a question via window.parent.postMessage(). Envelope proxies the request to Perplexity's Sonar API and sends back an AI-generated answer with web citations.
Using the Web Search SDK
The envelope.ai object is available globally; do NOT define your own helper function. Call it directly:
const { answer, citations } = await envelope.ai.ask(
'How many calories are in a banana?',
{ systemPrompt: 'You are a nutrition assistant. Be concise and accurate.' }
);
Usage Example
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
async function lookup(food) {
setLoading(true);
try {
const { answer, citations } = await envelope.ai.ask(
`How many calories are in ${food}? Give a concise answer with calorie and macro breakdown.`,
{ systemPrompt: 'You are a nutrition assistant. Be concise and accurate.' }
);
setResult({ answer, citations });
} catch (err) {
alert(err.message);
} finally {
setLoading(false);
}
}
Model Options
- sonar (default): Fast and balanced; good for most questions
- sonar-pro: More accurate, slightly slower; use for complex questions
- sonar-reasoning: Advanced reasoning; use for multi-step or analytical questions
Important Notes
- Perplexity has real-time web access; it can answer questions about current events, prices, weather, etc.
- Responses include citations (array of
{ url }objects); show these so users can verify information - Rate limit: 20 requests per minute per app
- Requests typically return in 3-10 seconds
- Always show a loading state while waiting for a response
- Always handle errors gracefully (timeouts, rate limits, network failures)
When to Use Web Search vs AI Chatbot
Use Web Search (this module) when the app needs live data from the internet:
- Price/stock trackers: Current stock prices, crypto rates, product prices
- News & sports apps: Breaking news, live scores, event results
- Weather apps: Current conditions, forecasts
- Nutrition trackers: Look up calories and macros from live nutrition databases
- Travel apps: Current flight prices, live hotel availability, visa requirements
- Fact-checking tools: Verify claims against current web sources
Use AI Chatbot (separate module) for conversational AI without web data:
- Study tools, tutoring, Q&A, coaching, brainstorming, creative writing, recommendation engines
Displaying Perplexity Results
ALWAYS render the answer text using the <Markdown> component; Perplexity responses contain markdown formatting (headers, bold, lists, etc.):
<Markdown>{result.answer}</Markdown>
NEVER render the answer as plain text or inside a <p> tag; it will show raw markdown syntax to the user.
Display citations below the answer as clickable links:
{result.citations?.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-xs text-muted-foreground font-medium">Sources</p>
{result.citations.map((c, i) => (
<a key={i} href={c.url} target="_blank" rel="noopener noreferrer"
className="text-xs text-primary underline block truncate">{c.url}</a>
))}
</div>
)}
Envelope Images SDK
Your app runs inside Envelope, which provides access to image picking, camera capture, AI image generation, and cloud storage. Use this to add image functionality without worrying about CSP restrictions.
How It Works
Your app sends requests via window.parent.postMessage(). Envelope handles file picking, camera access, AI generation, and storage in the parent frame, then sends the image data back to your app as a data URL.
Using the Images SDK
The envelope.images object and useImagePicker() hook are available globally.
Pick an image from device
const result = await envelope.images.pick({ persist: true });
// result: { dataUrl, url, width, height, mimeType }
Capture from camera
const photo = await envelope.images.capture({ camera: "user" });
// photo: { dataUrl, url, width, height, mimeType }
Generate an image with AI (text-to-image)
const generated = await envelope.images.generate("A cute cartoon cat wearing a top hat");
// generated: { dataUrl, width, height, mimeType }
Edit/transform an existing image with AI (image-to-image)
// Pass a reference image (dataUrl or persisted url) to transform it
const transformed = await envelope.images.generate(
"This animal wearing a pirate costume, professional studio photo",
{ image: result.url || result.dataUrl, persist: true }
);
// transformed: { dataUrl, url, width, height, mimeType }
When image is provided, the AI uses it as a reference and transforms it according to the prompt.
This is ideal for restyling photos, putting subjects in costumes, changing backgrounds, etc.
Upload a data URL to persistent storage
const { url } = await envelope.images.upload(myDataUrl);
// url: "/api/images/<id>"; persists across sessions
React Hook: useImagePicker()
All action methods return promises, so you can await them for the result directly:
const { result, loading, error, pickImage, captureImage, generateImage, clear } = useImagePicker();
// Pick from device; await returns the result directly
const photo = await pickImage({ persist: true });
// photo: { dataUrl, url, width, height, mimeType }
// Capture from camera
const snap = await captureImage({ camera: "user", persist: true });
// Generate with AI
const art = await generateImage("a sunset over mountains", { persist: true });
// Or use in onClick handlers (result available via hook state)
<Button onClick={() => pickImage({ persist: true })} disabled={loading}>
<Upload className="size-4" /> Pick Image
</Button>
<Button onClick={() => captureImage()} disabled={loading}>
<Camera className="size-4" /> Take Photo
</Button>
<Button onClick={() => generateImage("a sunset")} disabled={loading}>
<Sparkles className="size-4" /> Generate
</Button>
// Display result from hook state
{result && <img src={result.url || result.dataUrl} className="w-full rounded-lg" />}
{loading && <Skeleton className="w-full h-48" />}
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
React Hook: useImageGallery(storageKey?)
For apps that manage a collection of images (galleries, mood boards, photo diaries), use useImageGallery instead of manually managing arrays:
const { images, loading, error, addFromPick, addFromCapture, addFromGenerate, remove } = useImageGallery('my-photos');
// Add from device
<Button onClick={() => addFromPick()} disabled={loading}>
<Upload className="size-4" /> Upload
</Button>
// Add from camera
<Button onClick={() => addFromCapture()} disabled={loading}>
<Camera className="size-4" /> Take Photo
</Button>
// Add AI-generated
<Button onClick={() => addFromGenerate("a sunset over mountains")} disabled={loading}>
<Sparkles className="size-4" /> Generate
</Button>
// Display gallery grid
<div className="grid grid-cols-3 gap-1">
{images.map(img => (
<div key={img.id} className="relative aspect-square overflow-hidden">
<img src={img.url} className="h-full w-full object-cover" loading="lazy" />
<IconButton label="Remove" variant="ghost" size="sm" className="absolute top-1 right-1" onClick={() => remove(img.id)}>
<X className="size-3" />
</IconButton>
</div>
))}
</div>
Images are automatically persisted to cloud storage and URLs are saved to localStorage.
Each image entry has: { id, url, width, height, addedAt, prompt? }
Use useImageGallery when your app stores multiple images. Use useImagePicker when you only need one image at a time (e.g. avatar upload, single photo input).
Options
pick(options?) and capture(options?):
persist: boolean; upload to cloud storage (default: false, transient only)maxWidth: number; max resize width (default: 1200)maxHeight: number; max resize height (default: 1200)quality: number; JPEG quality 0-1 (default: 0.85)
capture(options?) also accepts:
camera: "user" | "environment"; front or back camera (default: "environment")
generate(prompt, options?):
image: string; a data URL or persisted URL of a reference image for image-to-image editing. When provided, the AI transforms this image according to the prompt instead of generating from scratch.persist: boolean; upload generated image to cloud (default: false)
Image Display Patterns
Grid layout: always use aspect-square to prevent layout shift:
<div className="grid grid-cols-3 gap-1">
{images.map(img => (
<div key={img.id} className="aspect-square overflow-hidden">
<img src={img.url} className="h-full w-full object-cover" loading="lazy" />
</div>
))}
</div>
Single image display:
<div className="aspect-square w-full overflow-hidden rounded-xl">
<img src={result.url || result.dataUrl} className="h-full w-full object-cover" />
</div>
Full-screen preview: use Dialog:
<Dialog open={!!selected} onOpenChange={() => setSelected(null)}>
<DialogContent className="p-0 max-w-[95vw] bg-black border-none">
<img src={selected?.url} className="max-h-[70vh] w-full object-contain" />
</DialogContent>
</Dialog>
When using persistent images with the Database module, store the url (not the dataUrl) in envelope.db:
await envelope.db.set('profile_photo', result.url);
Important Notes
- The creator must enable "Images" in their Envelope dashboard for this to work
- File picker and camera run in the parent frame: your app just calls the SDK
- Images are automatically resized to prevent large payloads
- AI generation rate limit: 10 per minute, 50 per day per app
- Upload rate limit: 10 per minute per app
- Max image size: 5MB
- Always show a loading state while images are being picked, captured, or generated
- Always handle errors gracefully
- Never store dataUrl in useLocalStorage: it's a large base64 blob that will exceed storage limits. Always use
persist: trueand store the returnedurlinstead - useImagePicker actions return promises:
const result = await pickImage({ persist: true })returns the image result directly. You can also use the hook'sresultstate - Lucide icons are globals: use
<Camera />,<Upload />,<Sparkles />,<Image />,<X />etc. directly. Do NOT import them
Envelope Voice Assistant SDK
Your app runs inside Envelope, which provides access to a real-time AI voice assistant powered by OpenAI. Use this when the user's app needs voice interaction, hands-free operation, or conversational AI.
How It Works
Your app sends commands via window.parent.postMessage(). Envelope handles microphone access, the AI connection, and audio playback. Your app only receives text events.
Using the Voice SDK
The useVoice() hook is available globally; do NOT define your own. Use it directly:
const { isActive, transcript, response, error, start, stop, ask } = useVoice();
// Start a voice session with optional system prompt
start('You are a cooking assistant. Help with recipes and timing.');
// Stop the session
stop();
// Send text without using the microphone
ask('What temperature should I bake chicken at?');
The hook returns:
isActive: boolean, is a session currently activetranscript: the user's transcribed speechresponse: the AI's text responseerror: error message string or nullstart(systemPrompt?): start a voice sessionstop(): stop the sessionask(text): send text to the voice assistant
UX Guidelines (MUST FOLLOW)
When building apps with voice, you MUST include proper UI so the user knows how to interact:
1. Prominent Mic Button Always include a large, obvious microphone button (min 56px tap target). Use an SVG mic icon. Place it at the bottom-center of the screen for easy thumb reach on mobile. The button must have distinct visual states:
- Idle: Gray/muted mic icon. Label or hint: "Tap to talk"
- Listening: Red or pulsing mic icon with a CSS pulse animation. Label: "Listening..."
- AI Speaking: A speaker/wave icon or animated dots. Label: "Speaking..."
- Error: Red-tinted button with brief error text below
Example pulse animation:
<style>
@keyframes pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.1); opacity: 0.8; } }
.listening { animation: pulse 1.5s ease-in-out infinite; }
</style>
2. Transcript & Response Display Show a visible area that displays:
- The user's transcribed speech (from
envelope:voice-transcript), so they see what was heard - The AI's text response (from
envelope:voice-response), so they can read along while audio plays
3. Onboarding Text Before the user starts their first voice session, show a brief instruction like "Tap the microphone to start talking" or "Press and hold to speak". Don't assume the user knows what to do.
4. Start/Stop Affordance Make it obvious how to end a session: tapping the mic button again should stop the session, or show a visible "Stop" / "End" button while a session is active.
5. Permission Handling
If the mic permission is denied (envelope:voice-error), show a friendly message like "Microphone access is needed. Please allow it in your browser settings." Don't just silently fail.
6. Mobile-First These apps run on phones. Use large touch targets (min 48px), generous spacing, and bottom-of-screen placement for the mic button. Avoid tiny controls or desktop-centric layouts.
Important Notes
- The creator must enable "Voice Assistant" in their Envelope dashboard for this to work. Always mention this when generating an app that uses voice.
- Microphone access and audio playback are handled by Envelope (the parent frame); your app only deals with text events.
- Always provide a visible button to start/stop voice sessions; never start automatically.
- Handle the
envelope:voice-errorevent gracefully (e.g., microphone denied, session timeout). - Voice works best on mobile devices with good microphone access.
- Voice sessions are real-time and use streaming audio; keep sessions short when possible.
Dictation Mode (Speech-to-Text Only)
Use dictation mode when your app needs speech-to-text input without an AI response: for example, typing into a text field by voice, voice-powered note-taking, or dictating a message.
IMPORTANT: Do NOT use voice assistant mode (start()) for dictation. The voice assistant generates an AI audio response, which is not what you want for speech-to-text input. Use startDictation() instead.
The same useVoice() hook supports dictation:
const { isDictating, transcript, error, startDictation, stopDictation } = useVoice();
// Start dictation: microphone opens, speech is transcribed to text
startDictation();
// Stop dictation: microphone closes, final transcript is delivered
stopDictation();
Dictation-specific state:
isDictating: boolean, is a dictation session currently activetranscript: accumulated transcribed text (builds up as the user speaks)
Key differences from voice assistant mode:
- No AI response is generated (no audio playback, no
responsetext) transcriptaccumulates text as the user speaks (in voice assistant mode it replaces each time)- Much cheaper to run (transcription only, no AI response tokens)
Dictation UX Guidelines
1. Text Input Integration
Show the transcribed text in a visible text area or input field as it arrives. Update the field in real-time using the transcript value from the hook.
2. Mic Button for Dictation Use a mic icon button next to or inside the text input. Visual states:
- Idle: Gray mic icon
- Dictating: Red/pulsing mic icon with "Dictating..." label
- Done: Mic returns to idle, transcribed text stays in the input
3. Editable Results Always let users edit the transcribed text after dictation stops. Voice transcription isn't perfect; users should be able to correct mistakes.
4. When to Use Dictation vs Voice Assistant
- Use dictation (
startDictation) when you need text input from speech (forms, notes, search, chat input) - Use voice assistant (
start) when you need an AI to respond to the user's speech (Q&A, tutoring, conversation) - Do NOT pipe voice assistant
transcriptinto a text input; use dictation for that
Complete Example: Dictation Input
function DictationInput({ onSubmit }) {
const { isDictating, transcript, error, startDictation, stopDictation } = useVoice();
const [text, setText] = useState('');
// Sync transcript to text field while dictating
useEffect(() => {
if (isDictating && transcript) setText(transcript);
}, [transcript, isDictating]);
return (
<div className="flex gap-2 items-end">
<Input value={text} onChange={e => setText(e.target.value)} placeholder="Tap mic to dictate..." className="flex-1" />
<Button variant={isDictating ? "destructive" : "ghost"} size="icon" onClick={() => isDictating ? stopDictation() : startDictation()}>
<Mic className={\`size-5 \${isDictating ? 'animate-pulse' : ''}\`} />
</Button>
{text.trim() && !isDictating && (
<Button size="icon" onClick={() => { onSubmit(text); setText(''); }}>
<Send className="size-4" />
</Button>
)}
</div>
);
}
Envelope Push Notifications SDK
Your app runs inside Envelope, which supports real push notifications to users' phones. When the user asks for notifications, reminders, or alerts, use this SDK instead of the browser Notification API directly.
How It Works
Your app communicates with Envelope's parent frame via window.parent.postMessage(). Envelope handles permission prompts, subscription management, and delivery.
Using the Push SDK
The envelope.push object is available globally; do NOT define your own helper. Call it directly:
// Send a notification to all devices
envelope.push.send('Reminder!', 'Time to drink water', { tag: 'water-reminder' });
// Send to current device only
envelope.push.send('Done!', 'Task completed', { target: 'self' });
// Subscribe the user (triggers permission prompt, returns result)
const result = await envelope.push.subscribe();
// result = { permission: 'granted'|'denied'|'default', subscribed: boolean }
// Check push status
const { available, permission, subscribed } = await envelope.push.status();
// Listen for permission denied (fires when subscribe is blocked)
envelope.push.onPermissionDenied((permission) => {
// Show the user instructions to re-enable notifications
});
Handling Blocked/Denied Permissions
If the user previously blocked notifications, envelope.push.subscribe() cannot re-trigger the browser prompt. You should check the status and show helpful guidance:
const status = await envelope.push.status();
if (status.permission === 'denied') {
// Show the user instructions to re-enable notifications
// The browser blocks re-prompting once denied, so the user must do it manually
}
You can also listen for the envelope:push-permission-denied event, which fires when a subscribe attempt is blocked:
window.addEventListener('message', (e) => {
if (e.data?.type === 'envelope:push-permission-denied') {
// Show instructions to re-enable
}
});
When permission is denied, show a friendly message like:
"Notifications are blocked. To enable them, tap the lock/settings icon in your browser's address bar, find Notifications, and set it to Allow. Then reload the app."
For iOS standalone PWAs, say:
"Notifications are turned off. Go to Settings > (this app) > Notifications and turn them on."
Always handle the denied case in any app that uses push notifications. Don't silently fail.
Important Notes
- Push notifications are real system notifications; they appear even when the app is closed
- The creator must enable "Push Notifications" in their Envelope dashboard for this to work. Always mention this when generating an app that uses push.
- Notifications are delivered to all devices that installed the app
- Do NOT use the browser
NotificationAPI directly; always go through the Envelope SDK via postMessage - Push works on Android and iOS (iOS 16.4+ in standalone PWA mode)
Envelope Geolocation SDK
Your app runs inside Envelope, which provides access to the device's GPS location. Use this to build location-aware features like store finders, weather by location, fitness trackers, or any app that needs to know where the user is.
How It Works
Your app sends location requests via window.parent.postMessage(). Envelope accesses the device's GPS in the parent frame and sends the coordinates back. The browser will prompt the user for permission the first time.
Using the Geolocation SDK
The envelope.geo object is available globally; do NOT define your own helper. Call it directly:
Usage Example: One-Shot Location
// Get the user's current position
const pos = await envelope.geo.getCurrentPosition();
console.log(pos.latitude, pos.longitude); // e.g. 37.7749, -122.4194
console.log(pos.accuracy); // accuracy in meters
Usage Example: Continuous Tracking
// Start watching position (returns a watchId)
const watchId = await envelope.geo.watchPosition((pos, err) => {
if (err) {
console.error('Watch error:', err.message);
return;
}
console.log('New position:', pos.latitude, pos.longitude);
});
// Later, stop watching
envelope.geo.clearWatch(watchId);
Usage Example: High Accuracy
// Request high accuracy (uses GPS, slower but more precise)
const pos = await envelope.geo.getCurrentPosition({
enableHighAccuracy: true,
timeout: 15000,
});
API Reference
envelope.geo.getCurrentPosition(options?): Returns a position object with latitude, longitude, accuracy, and more. Returns a Promise.envelope.geo.watchPosition(callback, options?): Starts continuous position tracking. Callscallback(position)on success, orcallback(null, error)on error. Returns a Promise that resolves to a watchId string.envelope.geo.clearWatch(watchId): Stops a position watch started bywatchPosition.
Position object: { latitude, longitude, accuracy, altitude, altitudeAccuracy, heading, speed, timestamp }
latitude/longitude: decimal degreesaccuracy: meters (typical: 5-100m outdoors, 50-500m indoors)altitude: meters above sea level (may be null)heading: degrees from north (may be null, requires movement)speed: meters per second (may be null, requires movement)
Options: { enableHighAccuracy?: boolean, timeout?: number, maximumAge?: number }
enableHighAccuracy: Use GPS for better precision (default: false). Uses more battery.timeout: Max time in ms to wait for a position (default: 10000)maximumAge: Accept a cached position up to this many ms old (default: 0)
Location Search (Autocomplete)
Search for places and addresses by text query. Use this to build location autocomplete inputs, address search, and place finders.
// Search for places
const results = await envelope.geo.searchPlaces("Central Park, New York");
// results: [{ displayName, lat, lng, type, address: { road, city, state, country, postcode } }]
// Search with options
const results = await envelope.geo.searchPlaces("coffee shop", {
limit: 3, // max results (1-10, default 5)
countrycodes: "us", // restrict to country (ISO 3166-1 alpha-2, comma-separated)
});
API: envelope.geo.searchPlaces(query, options?). Returns a Promise that resolves to an array of place results.
Options: { limit?: number, countrycodes?: string, viewbox?: string }
limit: Maximum number of results (1-10, default 5)countrycodes: Comma-separated ISO country codes to restrict results (e.g. "us", "us,ca")viewbox: Bounding box to bias results: "left,top,right,bottom" (longitude,latitude)
Result object: { displayName, lat, lng, type, address }
displayName: Full formatted address stringlat/lng: Coordinates (numbers)type: Place type (e.g. "restaurant", "residential", "city")address:{ road, city, state, country, postcode }; each field may be undefined
Location Autocomplete Example
function LocationSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [selected, setSelected] = useState(null);
const debounceRef = useRef(null);
function handleChange(e) {
const value = e.target.value;
setQuery(value);
clearTimeout(debounceRef.current);
if (value.length < 2) { setResults([]); return; }
debounceRef.current = setTimeout(async () => {
try {
const res = await envelope.geo.searchPlaces(value, { limit: 5 });
setResults(res);
} catch (err) {
console.error(err);
}
}, 400);
}
return (
<div className="relative">
<Input label="Search location" value={query} onChange={handleChange} />
{results.length > 0 && !selected && (
<div className="absolute z-10 w-full mt-1 bg-white rounded-lg shadow-lg border max-h-60 overflow-y-auto">
{results.map((r, i) => (
<button key={i} className="w-full text-left px-3 py-2 hover:bg-gray-100 text-sm"
onClick={() => { setSelected(r); setQuery(r.displayName); setResults([]); }}>
{r.displayName}
</button>
))}
</div>
)}
</div>
);
}
Reverse Geocoding
Convert coordinates to an address. Useful for getting a human-readable address from GPS position.
// Get address from coordinates
const place = await envelope.geo.reverseGeocode(37.7749, -122.4194);
console.log(place.displayName); // "City Hall, San Francisco, CA, USA"
console.log(place.address.city); // "San Francisco"
// Combine with GPS: get user's current address
const pos = await envelope.geo.getCurrentPosition();
const place = await envelope.geo.reverseGeocode(pos.latitude, pos.longitude);
console.log("You are at:", place.displayName);
API: envelope.geo.reverseGeocode(lat, lng). Returns a Promise that resolves to a place object.
Result object: { displayName, lat, lng, address: { road, city, state, country, postcode } }
Map Rendering
The Geolocation module includes a built-in map renderer powered by Leaflet. Use renderMap() to display interactive maps with markers, circles, and paths. The renderMap function is available as a global; do NOT import Leaflet yourself.
Map Example: Dark Map Style
const map = renderMap("map", { lat: 37.77, lng: -122.42, zoom: 13, tileStyle: "dark" });
map.addMarker(37.7749, -122.4194, { popup: "You are here" });
Map Example: Show User Location
function App() {
const [map, setMap] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let m;
async function init() {
try {
const pos = await envelope.geo.getCurrentPosition();
m = renderMap("map", { lat: pos.latitude, lng: pos.longitude, zoom: 15 });
m.addMarker(pos.latitude, pos.longitude, { popup: "You are here" });
m.addCircle(pos.latitude, pos.longitude, { radius: pos.accuracy });
setMap(m);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
init();
return () => { if (m) m.leaflet.remove(); };
}, []);
return (
<div className="h-screen flex flex-col">
{loading && <p className="p-4 text-center">Getting location...</p>}
<div id="map" className="flex-1" />
</div>
);
}
Map Example: Multiple Markers
const map = renderMap("map", { lat: 37.77, lng: -122.42, zoom: 12 });
map.addMarker(37.7749, -122.4194, { popup: "<b>San Francisco</b>" });
map.addMarker(37.3382, -121.8863, { popup: "San Jose" });
map.addMarker(37.8716, -122.2727, { popup: "Berkeley" });
map.fitAll(); // auto-zoom to show all markers
Map Example: Draw a Route
const map = renderMap("map", { lat: 37.77, lng: -122.42, zoom: 12 });
map.addMarker(37.7749, -122.4194, { popup: "Start" });
map.addMarker(37.3382, -121.8863, { popup: "End" });
map.addPath(
[[37.7749, -122.4194], [37.5585, -122.2711], [37.3382, -121.8863]],
{ color: "#ef4444", weight: 4 }
);
map.fitAll();
Map API Reference
renderMap(containerId, options): Creates an interactive map in the specified container element. Returns a map handle. The container element must exist in the DOM and have a defined height (use Tailwind classes like h-64, h-96, or flex-1 inside a flex parent).
Options: { lat, lng, zoom, tileStyle? }. Center coordinates, zoom level (default: 13), and optional map style.
Tile styles (pass as tileStyle):
"light": CartoDB Positron; clean, minimal light map (default)"default": Standard OpenStreetMap"dark": CartoDB Dark Matter; dark background, ideal for dark-themed apps"voyager": CartoDB Voyager; modern colorful style with labels"topo": OpenTopoMap; topographic with elevation contours (maxZoom: 17)
Map handle methods:
map.addMarker(lat, lng, options?): Add a pin. Options:{ popup: "HTML string", label: "text" }. Returns the Leaflet marker.map.addCircle(lat, lng, options?): Add a circle. Options:{ radius: 500, color: "#3b82f6", fillOpacity: 0.2, popup: "text" }. Radius is in meters.map.addPath(points, options?): Draw a polyline. Points:[[lat, lng], ...]. Options:{ color, weight, opacity, popup }.map.setView(lat, lng, zoom?): Pan/zoom the map to new coordinates.map.fitAll(padding?): Auto-zoom to fit all markers, circles, and paths. Optional padding (default 0.1).map.clear(): Remove all markers, circles, and paths from the map.map.leaflet: The raw LeafletL.mapinstance for advanced use (e.g. custom tile layers, GeoJSON, event listeners).
Background Behavior
When the app is backgrounded (user switches to another app or locks the screen), active position watches may be throttled by the OS. When the app returns to the foreground, Envelope automatically:
- Requests a fresh GPS position for all active watches
- Sends the updated position to your watch callback immediately
You can also register a callback for explicit foreground resume handling:
envelope.geo.onResume((position) => {
console.log('App resumed at:', position.latitude, position.longitude);
// Refresh map, recalculate distances, etc.
});
API: envelope.geo.onResume(callback). Register a callback that fires when the app returns to the foreground with the latest GPS position. Position object is the same format as getCurrentPosition.
Important Notes
- Permission required: The browser will show a permission prompt the first time. If the user denies it, the SDK returns an error. Handle this gracefully: offer a manual location input as fallback (city name, zip code).
- Permission denied is permanent per origin: Once denied, the browser won't re-prompt. Guide users to re-enable in browser settings if needed.
- Indoor accuracy: GPS accuracy drops significantly indoors (50-500m). Show accuracy info to users when it matters.
- Battery impact:
watchPositionwithenableHighAccuracy: trueuses significant battery. Only use it when actively tracking (e.g. during a run), and alwaysclearWatchwhen done. - Always handle errors: Wrap calls in try/catch. Common errors: "Permission denied", "Position unavailable", "Timeout".
- Show loading state: GPS can take 1-10 seconds. Always show a loading indicator while waiting.
- Map container must have height: The
<div>passed torenderMapmust have a CSS height set, or the map will be invisible. Use Tailwind:h-64,h-96,h-screen, orflex-1in a flex container. - Cleanup on unmount: Always call
map.leaflet.remove()in the useEffect cleanup to avoid memory leaks. - The creator must enable "Geolocation" in their Envelope dashboard for this to work.
- Always debounce search input (300-500ms) when using
searchPlacesfor autocomplete. Never call it on every keystroke. - searchPlaces is rate limited to 30 requests per minute. Debouncing ensures you stay within limits.
- Combine GPS + search: Use
getCurrentPositionto get the user's location, then offersearchPlacesas a fallback for manual input.
Game Engine SDK
The Game Engine module provides a lightweight toolkit for building mobile-first games. It handles game loops, canvas rendering, collision detection, touch controls, sound effects, particles, scene management, camera, tilemaps, and tweens, all running client-side in the iframe.
Architecture: "React Shell, Canvas Core"
- React handles: menus, HUD overlays, score displays, settings, game-over screens (as overlays)
- Canvas handles: the actual game rendering (sprites, backgrounds, effects)
- Store game state in refs (not useState) for the game loop; useState causes re-renders
- CRITICAL: Stale Closure Rule: The update/render callbacks capture variables at creation time. Any value you read inside update() or render() MUST come from a ref. If you check
screen(a useState value) inside update(), it will always see the initial value. Use a screenRef:
function App() {
const [screen, setScreen] = useState("menu");
const screenRef = useRef("menu"); // Mirror for game loop
const setGameScreen = (s) => { screenRef.current = s; setScreen(s); };
const scoreRef = useRef(0);
const [displayScore, setDisplayScore] = useState(0);
const { canvasRef } = useGameCanvas({
update(dt, ctx, gc) {
if (screenRef.current !== "playing") return; // Read from REF, not state
scoreRef.current += 1;
setDisplayScore(scoreRef.current); // Sync to React for display
},
render(dt, ctx, gc) {
ctx.clearRect(0, 0, gc.width, gc.height);
if (screenRef.current !== "playing") return;
// Draw game objects here
},
});
return (
<div style={{ position: "relative", width: "100%", height: "100vh" }}>
<div ref={canvasRef} style={{ width: "100%", height: "100%" }} />
{screen === "menu" && (
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
<button onClick={() => setGameScreen("playing")}>Start Game</button>
</div>
)}
{screen === "playing" && (
<div style={{ position: "absolute", top: 16, left: 16, color: "white", fontSize: 24 }}>
Score: {displayScore}
</div>
)}
</div>
);
}
Quick Start: Minimal Game
function App() {
const playerRef = useRef({ x: 187, y: 500, width: 30, height: 30 });
const [score, setScore] = useState(0);
const coinsRef = useRef([]);
const joystickRef = useRef(null);
const { canvasRef } = useGameCanvas({
onSetup(ctx, gc) {
// Spawn coins
for (let i = 0; i < 5; i++) {
coinsRef.current.push({
x: randomRange(20, gc.width - 20),
y: randomRange(20, gc.height - 100),
radius: 10,
});
}
// Create joystick for mobile
joystickRef.current = createJoystick({ zone: "left" });
},
update(dt) {
const p = playerRef.current;
const j = joystickRef.current;
if (j && j.active) {
p.x += j.x * 200 * dt;
p.y += j.y * 200 * dt;
}
// Coin collection
for (let i = coinsRef.current.length - 1; i >= 0; i--) {
const c = coinsRef.current[i];
if (rectCollision(p, { x: c.x - c.radius, y: c.y - c.radius, width: c.radius * 2, height: c.radius * 2 })) {
coinsRef.current.splice(i, 1);
playSoundEffect("coin");
setScore(s => s + 1);
}
}
},
render(dt, ctx, gc) {
ctx.clearRect(0, 0, gc.width, gc.height);
// Draw player
const p = playerRef.current;
ctx.fillStyle = "#4F46E5";
ctx.fillRect(p.x, p.y, p.width, p.height);
// Draw coins
ctx.fillStyle = "#FFD700";
coinsRef.current.forEach(c => {
ctx.beginPath();
ctx.arc(c.x, c.y, c.radius, 0, Math.PI * 2);
ctx.fill();
});
},
});
return (
<div style={{ position: "relative", width: "100%", height: "100vh", background: "#1a1a2e" }}>
<div ref={canvasRef} style={{ width: "100%", height: "100%" }} />
<div style={{ position: "absolute", top: 16, right: 16, color: "white", fontSize: 20, fontWeight: "bold" }}>
{score}
</div>
</div>
);
}
API Reference
useGameCanvas(opts): React Hook (Recommended)
Creates a canvas with a game loop, auto-cleans up on unmount.
opts.fps: Target FPS (default 60)opts.onSetup(ctx, gc): Called once when canvas is readyopts.update(dt, ctx, gc): Fixed timestep update (dt in seconds)opts.render(dt, ctx, gc): Render frame- Returns
{ canvasRef, width, height, gameCanvas } - Attach
canvasRefto a<div>container (NOT a canvas element)
createGameLoop({ update, render, fps? })
Manual game loop (use when not using the React hook).
update(dt): dt is fixed timestep in seconds (e.g., 1/60)render(dt): called after update- Returns
{ start(), stop(), isRunning() } - Auto-pauses when tab is hidden, resumes on return
createGameCanvas(containerId, opts?)
Create a Retina-aware canvas in a container element.
opts.pixelRatio: Override device pixel ratio- Returns
{ canvas, ctx, width, height, destroy() }
Collision Detection
rectCollision(a, b): AABB collision. Objects need{ x, y, width, height }circleCollision(a, b): Circle collision. Objects need{ x, y, radius }pointInRect(px, py, rect): Point inside rectangle
Do NOT define rectCollision or circleCollision yourself; they are provided globally by the Game Engine SDK. Defining them again causes "Identifier has already been declared" errors.
Entity System
createEntity({ x, y, width, height, radius, vx, vy, tag, active, data })createEntityPool(): Pool with:.add(entity),.remove(entity),.getByTag(tag),.getAll().checkCollisions(tagA, tagB, callback): callback(entityA, entityB).removeInactive(),.clear()
Touch Controls (Mobile)
createJoystick(opts?): Virtual analog joystickopts.zone: "left" (default), "right", "full"opts.fixed: true for static position, false for dynamic (appears on touch)opts.size: diameter in pixels (default 120)- Returns
{ x, y, active, angle, magnitude, destroy() }(x/y are -1 to 1)
createGameButtons(buttons): Action buttons- buttons:
[{ id, label, x?, y?, size? }] - Returns
{ isPressed(id), destroy() }
- buttons:
createTapZones(zones): Tap regions- zones:
[{ id, x, y, width, height }](in % of screen, 0-100) - Returns
{ onTap(id, callback), destroy() }
- zones:
Important: Always call .destroy() on controls when done (e.g., in useEffect cleanup or scene exit).
Particle Effects
createParticleEmitter(opts?): Burst particlesopts:{ colors, count, speed, lifetime, size, gravity, spread }.emit(x, y, overrides?): Spawn particles at position.update(dt): Call in game loop update.render(ctx): Call in game loop render.alive: Number of active particles
Scene Manager
createSceneManager().add(name, { enter?, update?, render?, exit? }).set(name): Switch scene (calls exit on old, enter on new).current: Current scene name.update(dt),.render(ctx, dt): Delegate to current scene
Camera (for scrolling games)
createCamera({ width, height, worldWidth, worldHeight, lerp? }).follow(target): Smooth follow { x, y }, call in update.apply(ctx): Apply transform before rendering.restore(ctx): Restore after rendering.screenToWorld(sx, sy): Convert screen to world coords
Tilemap (for platformers, mazes)
createTilemap({ tileSize, data, colors })data: 2D arraydata[row][col], 0 = emptycolors:{ 1: "#8B4513", 2: "#228B22" }or draw functions.getTile(col, row),.setTile(col, row, value).collidesWithSolid(x, y, width, height, solidValues?): Returns{ col, row, tile }or null.render(ctx, cameraX?, cameraY?, viewWidth?, viewHeight?)
Timers & Tweens
createGameTimer(duration, callback?).update(dt): Call in game loop.done,.elapsed,.progress(0-1),.restart()
tween(from, to, duration, easing?).update(dt),.value,.done,.restart()- Easing: "linear", "easeInQuad", "easeOutQuad", "easeInOutQuad", "easeOutBounce", "easeOutElastic"
Sound Effects
playSoundEffect(type): Procedural game sounds via Web Audio- Types: "jump", "hit", "collect", "coin", "explosion", "powerup", "laser", "gameover", "click", "swoosh"
- CSP-safe: no external audio files needed
Math Utilities
randomRange(min, max): Random float in rangerandomInt(min, max): Random integer in range (inclusive)gameClamp(value, min, max): Clamp value (aliased to avoid conflict with CSS clamp)gameLerp(a, b, t): Linear interpolation (aliased)gameDist(x1, y1, x2, y2): Distance between points (aliased)gameAngle(x1, y1, x2, y2): Angle in radiansgameNormalize(x, y): Normalize vector to unit length, returns { x, y }
Game Type Patterns
Endless Runner / Flappy Bird:
- Scroll obstacles left, player stays at fixed X
- Tap/click to jump: gravity pulls down (vy += GRAVITY * dt), tap sets vy = negative FLAP_STRENGTH
- Pipes spawn off right edge at intervals, scroll left; gap Y randomized per pair
- Score +1 when player X passes pipe X + width; game over on collision with pipe/floor/ceiling
- Use screenRef pattern (NOT useState) for checking game state in update/render callbacks
- Increase speed over time for difficulty
Platformer:
- Use tilemap for levels, camera for scrolling
- Apply gravity + check tilemap collision each frame
- Joystick for movement, button for jump
Snake / Grid Games:
- Use a timer to move at fixed intervals (e.g., 150ms)
- Store body as array of {x,y} grid positions
- No canvas needed; use React divs with absolute positioning
Card / Puzzle / Quiz Games:
- Pure React (no Canvas needed); use Tailwind for layout
- Use useState for game state, animations with CSS transitions
- Touch: standard onClick/onTouchStart on elements
Tap / Reaction Games:
- Use createTapZones or simple touch handlers
- Spawn targets at random positions
- Timer for time pressure
Constraints
- Sprites from URLs (primary approach): When you have sprite URLs (from the Character Sprites section above, or uploaded assets), you MUST load them as real images; this is the primary rendering approach. Use this resilient loader that handles proxy failures:
// Define once at top of your code
function loadSprite(url, onLoaded) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => onLoaded(img);
img.onerror = () => {
// Proxy failed; try direct CDN URL as fallback
const direct = new Image();
direct.crossOrigin = "anonymous";
direct.onload = () => onLoaded(direct);
direct.onerror = () => {}; // give up silently, shape fallback renders
direct.src = url;
};
img.src = typeof envelope !== "undefined" && envelope.proxyImageUrl
? envelope.proxyImageUrl(url)
: url;
}
// Call in onSetup:
loadSprite("https://example.com/sprite.png", (img) => { gc.playerSprite = img; });
Then in render, ALWAYS guard drawImage: if (sprite?.complete && sprite.naturalWidth > 0) ctx.drawImage(sprite, x, y, w, h); else { /* colored circle fallback while loading */ }. Procedural shapes are ONLY a temporary fallback while images load, never a replacement.
- Builder assets: When the creator uploads images in the Assets tab, use their URLs directly (e.g.
/api/builder-assets/abc-123); same pattern:new Image(),img.src = url,onload, store ongc. - Procedural drawing (fallback only): Only use procedural drawing, emoji, or generated images when no sprite URLs are available AND no assets were uploaded. Without External Data or uploaded assets, CSP blocks direct external img src.
- No WebGL; use Canvas 2D only
- Keep entity count under ~200 for smooth 60fps on mobile
- Sound requires user interaction first (browser policy); play first sound on a tap/click
- All game state that the game loop reads MUST be in refs, not useState; this includes screen state (use screenRef pattern), positions, velocities, entity lists, timers
- Never check a useState variable inside update() or render(); it will be stale. Mirror it in a ref.
- Clean up controls (joystick.destroy(), buttons.destroy()) when switching screens
- Never use Math.random() inside render(); it runs every frame so random values will flicker/shimmer. Pre-generate random positions (e.g., seed coordinates, decorative elements) in onSetup or when spawning entities, store them on the object, and read them in render.
Envelope AI Workflows SDK
Your app runs inside Envelope, which provides access to AI Workflows: multi-step automations that chain together AI calls, database operations, conditions, and more. Workflows are defined by the app creator in the Configure tab and executed server-side.
How It Works
Workflows are pre-defined automation pipelines. Your app triggers them by name and receives the result. The workflow runs entirely on the server; your app just sends the trigger and gets back the output.
Using the Workflows SDK
// Run a workflow by name with input data
const result = await envelope.workflow.run("process-order", {
orderId: "abc123",
customerName: "Jane"
});
if (result.success) {
console.log("Workflow output:", result.data);
} else {
console.error("Workflow failed:", result.error);
}
Available Methods
envelope.workflow.run(name, inputData?): Trigger a workflow and wait for the result. Returns{ success: boolean, data?: any, error?: string }.envelope.workflow.list(): Get all available workflows for this app. Returns{ workflows: Array<{ name: string, description: string }> }.
Usage Example
function App() {
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
async function handleAnalyze() {
setLoading(true);
setError(null);
try {
const res = await envelope.workflow.run("analyze-text", {
text: "The product is amazing but shipping was slow"
});
if (res.success) {
setResult(res.data);
} else {
setError(res.error);
}
} catch (err) {
setError("Failed to run workflow");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-background text-foreground p-4">
<h1 className="text-xl font-bold mb-4">Sentiment Analyzer</h1>
<Button onClick={handleAnalyze} disabled={loading}>
{loading ? "Analyzing..." : "Analyze"}
</Button>
{error && <Alert variant="destructive" className="mt-3"><AlertDescription>{error}</AlertDescription></Alert>}
{result && (
<Card className="mt-3">
<CardContent className="p-3">
<p className="text-sm font-medium">Sentiment: {result.sentiment}</p>
<p className="text-xs text-muted-foreground mt-1">{result.summary}</p>
</CardContent>
</Card>
)}
</div>
);
}
Defining Workflows
When your app uses envelope.workflow.run(), you must also define the workflow by including a workflow-json code block **after** your jsx code block. Each workflow-json block defines one workflow as a JSON object.
IMPORTANT: Always output the jsx code block FIRST with your full App component, THEN output one or more workflow-json blocks after it. Never put workflow-json before jsx.
Example workflow-json block (output this AFTER your jsx block):
{"name":"analyze-text","description":"Analyze sentiment of text","trigger":"code","nodes":[{"id":"trigger-1","type":"trigger","position":{"x":250,"y":0},"config":{"triggerType":"code"},"label":"Trigger"},{"id":"ai-1","type":"ai-prompt","position":{"x":250,"y":120},"config":{"prompt":"Analyze the sentiment of: {{input.text}}","model":"flash","outputKey":"analysis"},"label":"Analyze"},{"id":"output-1","type":"output","position":{"x":250,"y":240},"config":{"outputKey":"analysis"},"label":"Output"}],"edges":[{"id":"e1","source":"trigger-1","target":"ai-1"},{"id":"e2","source":"ai-1","target":"output-1"}]}
Webhook-triggered workflows
A workflow with trigger: "webhook" fires only when an external POST hits /api/workflow/webhook/[slug]; it will NOT run when your app calls envelope.workflow.run(...). If you want the app to trigger a workflow, use trigger: "code" instead.
Hard rule: If trigger: "webhook", do NOT call envelope.workflow.run(...) from the app; the workflow fires only from external POSTs. Use trigger: "code" if you want the app to trigger it.
Webhook bodies are available to downstream nodes via {{input.x}} (same template syntax as ai-prompt and integration-call params). Example: receives a POSTed { "text": "..." } and summarizes it:
{"name":"summarize-incoming","description":"Summarize text posted to the webhook","trigger":"webhook","nodes":[{"id":"trigger-1","type":"trigger","position":{"x":250,"y":0},"config":{"triggerType":"webhook"},"label":"Trigger"},{"id":"ai-1","type":"ai-prompt","position":{"x":250,"y":120},"config":{"prompt":"Summarize in one sentence: {{input.text}}","model":"flash","outputKey":"summary"},"label":"Summarize"},{"id":"output-1","type":"output","position":{"x":250,"y":240},"config":{"outputKey":"summary"},"label":"Output"}],"edges":[{"id":"e1","source":"trigger-1","target":"ai-1"},{"id":"e2","source":"ai-1","target":"output-1"}]}
Full example: webhook + Slack integration-call + KV write (copy this shape exactly when a webhook should fan out to an OAuth-connected service and persist data). Every node has id, type, position, config, label. Every edge has id, source, target, NOT from/to. integration-call uses the key provider, NOT integration:
{
"name": "notify-slack-on-signup",
"description": "When a signup is posted to the webhook, notify #general and persist to KV",
"trigger": "webhook",
"nodes": [
{ "id": "trigger-1", "type": "trigger", "position": { "x": 250, "y": 0 }, "config": { "triggerType": "webhook" }, "label": "Webhook" },
{ "id": "slack-1", "type": "integration-call", "position": { "x": 250, "y": 120 }, "config": { "provider": "slack", "method": "sendMessage", "params": { "data": { "channel": "#general", "text": "🎉 New signup: {{input.name}} ({{input.email}})" } }, "outputKey": "slackResult" }, "label": "Notify Slack" },
{ "id": "kv-1", "type": "kv-write", "position": { "x": 250, "y": 240 }, "config": { "key": "signup:{{input.email}}", "valueExpression": "({ name: input.name, email: input.email, at: new Date().toISOString() })" }, "label": "Persist Signup" },
{ "id": "output-1", "type": "output", "position": { "x": 250, "y": 360 }, "config": {}, "label": "Output" }
],
"edges": [
{ "id": "e1", "source": "trigger-1", "target": "slack-1" },
{ "id": "e2", "source": "slack-1", "target": "kv-1" },
{ "id": "e3", "source": "kv-1", "target": "output-1" }
]
}
See Webhook security below for HMAC signing requirements when the workflow contains an integration-call node.
Available node types and their configs:
trigger: Entry point. Config:{ triggerType: "code" | "webhook" | "schedule" }ai-prompt: Call an AI model. Config:{ prompt, systemPrompt?, model?: "flash"|"pro", outputKey }. Use{{variable}}for template substitution.perplexity: Web search. Config:{ query, model?: "sonar"|"sonar-pro"|"sonar-reasoning", outputKey? }condition: Branch on expression. Config:{ expression }. Returns true/false, routes via "true"/"false" edge handles.transform: Evaluate expression. Config:{ expression, outputKey }kv-read: Read from DB. Config:{ key, outputKey? }kv-write: Write to DB. Config:{ key, valueExpression }push-send: Send push notification. Config:{ title, body }. Use{{variable}}templates.webhook: HTTP request. Config:{ url, method?, headers?, bodyExpression?, outputKey? }. HTTPS only.integration-call: Call a connected integration. Config:{ provider, method, params?, outputKey? }. Params support{{template}}values. The provider must be connected by the app creator.- HubSpot (
provider: "hubspot"): Methods:listContacts,getContact,createContact,updateContact,deleteContact,listCompanies,getCompany,createCompany,updateCompany,listDeals,getDeal,createDeal,updateDeal,listPipelines,listOwners,searchContacts,searchCompanies,searchDeals. Create/update params:{ data: { properties: { email, firstname, lastname, ... } } }. List params:{ limit, after, properties }. - Slack (
provider: "slack"): Methods:listChannels,getChannelHistory,getChannelInfo,sendMessage,updateMessage,deleteMessage,getThreadReplies,listUsers,getUserInfo,getUserByEmail,getTeamInfo,addReaction,removeReaction. sendMessage params:{ data: { channel: "C...", text: "Hello" } }. listChannels params:{ limit, cursor, types }.
- HubSpot (
delay: Pause. Config:{ seconds }(max 30).loop: Iterate. Config:{ arrayExpression, itemKey }output: Return result. Config:{ outputKey? }(omit to return all data).
Rules for workflow definitions:
- Every workflow must have exactly one
triggernode and at least oneoutputnode - Nodes are connected via
edges(source → target) - For condition nodes, use
sourceHandle: "true"orsourceHandle: "false"on edges - Space node positions vertically (y += 120 per step) for a clean layout
- The workflow
namemust match what your code passes toenvelope.workflow.run() - You can define multiple workflows with separate ```workflow-json blocks
Webhook security: When the workflow trigger is webhook AND the workflow contains an integration-call node, Envelope requires HMAC-signed requests. Senders must send header X-Envelope-Signature: t=<unix>,v1=<hex> where v1 is hmac-sha256(secret, ${t}.${rawBody}).hex(). The secret is generated when the workflow is created or the trigger switches to webhook; the creator can view and regenerate it in the workflow settings. Requests older than 5 minutes are rejected. Workflows without integration-call nodes (pure data transforms) do not require signing.
Common mistakes
- Slack / HubSpot notifications in workflows → always use
integration-call, never thewebhooknode. The creator connects these providers via OAuth in the Integrations panel; a rawwebhooknode pointed athooks.slack.comorapi.hubapi.comwould require the app to hold credentials and will fail without them. - Calling
envelope.workflow.run(...)on a workflow whosetriggeris"webhook"or"schedule": that call will never succeed from the app. Usetrigger: "code"when the app needs to trigger it. - Writing the workflow as a JavaScript comment (
/** WORKFLOW DEFINITION ... */) inside the jsx block. That is ignored. The workflow MUST be emitted as a separate fenced code block with the language tagworkflow-json, after the jsx block. - Wrong edge shape: edges use
{ "id", "source", "target" }. Do NOT usefrom/to. - Wrong integration-call config: use
{ "provider": "slack", "method": "sendMessage", ... }. Do NOT useintegrationas the key. - Missing
triggeroroutputnodes. Every workflow needs exactly onetriggernode AND at least oneoutputnode, even if the output is just{ "config": {} }. - Missing
positionon nodes. Every node needsposition: { x, y }(space steps 120px apart vertically). - The workflow
namein the ```workflow-json block must match the string passed toenvelope.workflow.run(...)exactly; mismatched names silently fail. - Reserve the
webhooknode for generic HTTPS endpoints (your own API, third-party REST APIs without an integration). For anything Envelope supports as an integration, preferintegration-call.
Important Notes
- Workflows run server-side; they can access the database, send push notifications, call AI, etc.
- Your app triggers workflows and handles results; the workflow definition describes the server-side steps
- Rate limit: 20 workflow executions per minute per app
- Each workflow execution has a 30-second timeout
- Always handle errors; workflows can fail if a node errors or times out
Envelope Integrations SDK
Your app can connect to third-party services through Envelope's Integrations system.
Each integration provides typed methods on the envelope.integrations object.
How It Works
- Users connect their accounts via OAuth (handled by Envelope, not your app)
- Your app calls methods like
envelope.integrations.hubspot.createContact({ data: { properties: { email: "..." } } }) - Requests are proxied through Envelope's server with the user's credentials
- All methods return Promises
IMPORTANT: Always Show a Connection Screen
Every app using integrations MUST check connection status on load and show a clear connect screen if not connected. Never assume the user is already connected.
Exception: owner-connected integrations (Google Sheets): these are connected once by the app's creator, not by end users. Do NOT render a "Connect" button or call
connect()for them; see the Google Sheets section below. The pattern below applies to user-connected integrations (HubSpot, Slack).
For user-connected integrations, always include:
- A friendly connection screen with the integration name and a "Connect" button
- A hint telling the user: "Make sure Integrations is enabled in the Configure tab"
- A connected state indicator (e.g. a green "Connected" badge in the header)
Example pattern:
const [isConnected, setIsConnected] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
envelope.integrations.hubspot.isConnected().then(connected => {
setIsConnected(connected);
setLoading(false);
});
}, []);
if (loading) return <LoadingState />;
if (!isConnected) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
<h1 className="text-2xl font-bold mb-2">Connect HubSpot</h1>
<p className="text-muted-foreground mb-2">
Connect your HubSpot account to get started.
</p>
<p className="text-xs text-muted-foreground mb-6">
Make sure Integrations is enabled in the Configure tab.
</p>
<Button onClick={() => envelope.integrations.hubspot.connect().then(() => setIsConnected(true))}>
Connect HubSpot
</Button>
</div>
);
}
Error Handling
Always wrap integration calls in try/catch. If a call fails with "not_connected", show the connect screen again:
try {
const contacts = await envelope.integrations.hubspot.listContacts();
} catch (err) {
if (err.message === "not_connected") {
setIsConnected(false); // Show connect screen
} else {
console.error("API error:", err.message);
}
}
Owner-connected integrations (Google Sheets) fail differently. The end user can't fix the connection; only the creator can. Treat any rejection from a googlesheets call as an owner-side problem; do NOT string-match a specific code, because only the missing-token case rejects with the literal "owner_not_connected"; a deleted or broken sheet rejects with a human-readable sentence instead. On any such failure, do NOT show a connect screen or call connect(). Show a short message pointing at the app owner, e.g. "This app's Google Sheet needs attention from the app owner." Keep the rest of the app usable where possible (e.g. still let the user fill out the form; just surface that saving is temporarily unavailable).
Common Pitfalls: AVOID THESE
-
Never invent method names. Only use the exact names listed below. Wrong:
listPublicChannels,postMessage,getContacts. Right:listChannels,sendMessage,listContacts. -
Never put integration fetches in useCallback deps that update state from the same fetch. This causes infinite loops. Use
[]deps for data-fetching callbacks and call them from a singleuseEffect(fn, []). -
Always pass explicit strings to handleConnect. Wrong:
onClick={handleConnect}(passes the event object). Right:onClick={() => handleConnect('hubspot')}. -
GET methods (list/get) use flat params. POST methods (create/update/send) use
{ data: { ... } }.- GET:
listContacts({ limit: 10, properties: "email,firstname" }) - POST:
createContact({ data: { properties: { email: "..." } } }) - POST:
sendMessage({ data: { channel: "C...", text: "Hi" } })
- GET:
-
Check connection once on mount, not in every fetch.
// GOOD: Check once, fetch separately
useEffect(() => {
async function init() {
const connected = await envelope.integrations.hubspot.isConnected();
setIsConnected(connected);
if (connected) fetchContacts();
}
init();
}, []);
// BAD: Checking connection inside fetchContacts causes loops
Important Notes
- Each user connects their own account; their credentials are never shared
- OAuth tokens are automatically refreshed when they expire
- All API calls are rate-limited per integration
- The Integrations module must be enabled in the Configure tab before connecting
- ONLY use the exact method names listed below. Do NOT invent method names.
Available Methods (use ONLY these exact names)
HubSpot (envelope.integrations.hubspot):
listContacts({ limit?, after?, properties? }): List contactsgetContact({ contactId, properties? }): Get contact by IDcreateContact({ data: { properties: { email, firstname, lastname, phone, company, ... } } }): Create contactupdateContact({ contactId, data: { properties: { ... } } }): Update contactdeleteContact({ contactId }): Delete contactlistCompanies({ limit?, after?, properties? }): List companiesgetCompany({ companyId, properties? }): Get company by IDcreateCompany({ data: { properties: { name, domain, industry, ... } } }): Create companyupdateCompany({ companyId, data: { properties: { ... } } }): Update companylistDeals({ limit?, after?, properties? }): List dealsgetDeal({ dealId, properties? }): Get deal by IDcreateDeal({ data: { properties: { dealname, amount, dealstage, pipeline, ... } } }): Create dealupdateDeal({ dealId, data: { properties: { ... } } }): Update deallistPipelines(): List deal pipelines and stageslistOwners({ limit?, after? }): List CRM ownerssearchContacts({ data: { query?, filterGroups?, sorts?, properties?, limit? } }): Search contactssearchCompanies({ data: { ... } }): Search companiessearchDeals({ data: { ... } }): Search deals
Slack (envelope.integrations.slack):
listChannels({ limit?, cursor?, types? }): List channels (NOT listPublicChannels)getChannelHistory({ channel, limit?, cursor? }): Get messages from a channelgetChannelInfo({ channel }): Get channel infosendMessage({ data: { channel: "C...", text: "Hello", blocks?, thread_ts? } }): Send a messageupdateMessage({ data: { channel, ts, text, blocks? } }): Update a messagedeleteMessage({ data: { channel, ts } }): Delete a messagegetThreadReplies({ channel, ts, limit? }): Get thread replieslistUsers({ limit?, cursor? }): List workspace membersgetUserInfo({ user }): Get user info by IDgetUserByEmail({ email }): Find user by emailgetTeamInfo(): Get workspace infoaddReaction({ data: { channel, timestamp, name } }): Add emoji reactionremoveReaction({ data: { channel, timestamp, name } }): Remove emoji reaction
IMPORTANT: The data param is sent directly as the request body to the API. For HubSpot create/update, the body must be { properties: { ... } }. For Slack sendMessage, the body must be { channel, text }.
Google Sheets (envelope.integrations.googlesheets), owner-connected:
Use this when the user wants to save app data into a Google Sheet (logs, form submissions, trackers, signups). Unlike HubSpot/Slack, the app's creator connects Google once in the Configure tab and every end user shares that one sheet; end users do NOT connect their own account.
- Do NOT render a "Connect" button and do NOT call
connect(): it rejects for end users. The creator connects in Configure. - ALWAYS gate the app on
await envelope.integrations.googlesheets.isConnected()at load (with a loading state while it resolves). If it returnsfalse, render a short "not set up yet" message like "This app's Google Sheet isn't set up yet; the app owner needs to finish setting it up." (no connect button; never mention creator-only UI like "the Configure tab" to end users) instead of the main UI. This is required, not optional; without it the app throws confusing errors on a fresh or broken connection. - If a call later fails with
"owner_not_connected"(the owner's connection broke after load), show the same owner-attention message rather than crashing (see Error Handling above). - Never pass a
spreadsheetId: Envelope automatically targets the creator's configured sheet.
Methods (use ONLY these exact names):
appendRow({ range, body: { values: [[...]] } }): append one or more rows. Always anchorrangeto explicit columns starting at A, e.g."Sheet1!A:D"for 4 columns, NOT a bare tab name like"Sheet1". A bare tab name makes Google's append drift each row sideways whenever a cell is empty, scattering data across columns.body.valuesis an array of rows (each row an array of cell values).getValues({ range }): read a range, e.g."Sheet1!A1:C100". Returns{ range, majorDimension, values }.updateValues({ range, body: { values: [[...]] } }): overwrite the cells in a range.clearValues({ range }): clear values in a range, leaving an empty row (formatting is kept).deleteRow({ rowIndex }): permanently remove a row and shift the rows below it up.rowIndexis 0-based (the first row is 0). Use this (notclearValues) when you want the entry gone with no blank gap left behind. PasssheetId(numeric tab id) too if you're not targeting the first tab.addSheet({ title }): create a new tab. Wrap in try/catch and ignore the error if a tab with that title already exists.getSheetInfo(): get spreadsheet metadata, including the list of existing tab names.
The connected spreadsheet starts EMPTY with a single tab named Sheet1: no other tabs, no headers, no data. So:
- If your app uses named tabs (e.g.
"Habits","Log"), CREATE them on first load withaddSheet({ title })(in try/catch, ignoring already-exists), then optionallyappendRowa header row. Never tell the end user to go create tabs or columns by hand in Google Sheets; the app must set up its own structure. - If you only need one tab, just use
Sheet1. - A missing tab or an empty range is NORMAL for a fresh sheet. Treat "no values returned" as an empty state ("no entries yet"), not as a sync error; don't show scary error messages for a brand-new sheet.
To remove a specific entry, store a stable key (e.g. an id or timestamp) in a column when you append it. To delete it later: getValues the key column, find the array index of the matching row, then deleteRow({ rowIndex }) with that index. Deleting shifts rows up, so always re-read before deleting again rather than reusing a stale index.
Example: append a mood entry:
await envelope.integrations.googlesheets.appendRow({
range: "Sheet1!A:C", // anchor to columns A–C (timestamp, rating, notes)
body: { values: [[new Date().toISOString(), moodRating, notes]] },
});
Reading data back: mind the shared sheet. The connected sheet is shared by every end user of the app, so getValues returns all users' rows, not just the current user's.
- For personal/private apps (mood tracker, habit log, anything where one user must not see another's entries): keep each user's own view in
useLocalStorage(orenvelope.dbwithscope: "user"when Auth is on), and use the Google Sheet purely as the creator's collection/export destination. Do NOT rendergetValuesoutput to the end user; it would leak everyone's data onto one person's screen. Write withappendRow; read the user's own history from local state. - For shared/collaborative apps (team activity log, public leaderboard, a group sign-up list everyone is meant to see):
getValuesis exactly right: reading all rows back is the intended behavior.
Custom integrations
Creators can define their own OAuth2 integrations (in /dashboard/integrations). They appear under envelope.integrations["custom:<uuid>"] and are callable the same way as the built-ins: .isConnected(), .connect(), and each declared method. If the app has any custom integrations enabled, an additional "Custom Integrations (app-specific)" section is appended to this prompt with the exact method catalog you are allowed to use. Only use methods listed in that section; do not invent names.