name: vision-extension-development
description: Build custom Vision extensions for the 1up.vision streaming platform. Use when the user wants to create overlays, editor panels, interactive pages, or server functions for Vision extensions. Triggers on tasks involving Vision SDK components, overlay UI, extension targets (editor/layer/interactive), vision.config.json, extension font setup (fonts, localFont), Vision.render(), useExtensionStorage, useExtensionContext, useQuery/useMutation hooks, server schema/functions, the vision CLI (vision init, vision dev, vision deploy), or building streaming overlays for OBS.
Vision Extension Development
This skill provides instructions for building custom Vision extensions on the 1up.vision platform.
Skill Modules
This skill is split into focused docs in this folder. Use these files first for targeted guidance:
auth.md-- end-to-end auth model, default invocation permissions, and caller identity (who called this function?)server-functions.md-- declarative builders, hook mapping (useQueryvsuseMutation), and action invocation patternsruntime-scopes.md-- table storage scopes (instance/overlay/account/global) and runtime selector behaviorcomponents.md-- SDK component primitives and style constraintscli-workflows.md--vision dev/vision deploy, egress config, and release flowbest-practices.md-- practical implementation playbooks (layer UI, function design, and security defaults)troubleshooting.md-- common errors and exact fixes
When adding new behavior, update the relevant module(s) and keep this file's module index in sync.
When to Use This Skill
Use this skill when the user:
- Wants to create a new Vision extension (
vision init, scaffolding, project setup) - Is building or editing overlay UI for OBS streams (layer target)
- Is building editor panels or interactive participant/control pages
- Asks about Vision SDK components (
Box,Text,Button,CompactView, etc.) - Uses or asks about hooks like
useExtensionStorage,useExtensionContext,useQuery,useMutation - Needs to load fonts in an extension (
vision.config.jsonfonts,localFont, local font assets inpublic/) - Needs to define server-side schema, functions, or secrets (
server/schema.ts,server/functions.ts) - Works with
vision.config.jsonor the Vision CLI (vision dev,vision deploy,vision env) - Asks about
Vision.render(), extension targets, or the sandbox architecture - Wants to style extension components with
VisionStyle - Needs to connect editor settings to an OBS overlay (the editor + layer pattern)
How to Build an Extension (Step by Step)
Step 1: Identify the Extension Type
Determine which targets the extension needs:
| User wants to... | Targets needed |
|---|---|
| Add a configurable overlay to OBS | editor + layer |
| Build participant/control interactions | editor + interactive |
| Build a full experience with all surfaces | editor + layer + interactive |
| Add a settings panel only | editor |
Use interactive for active session participants and controls, not as a high-scale public page for thousands of concurrent viewers.
Step 2: Scaffold and Configure (Required First Run)
Run vision init first for every new extension. This creates vision.config.json and registers the extension on 1up.vision when you're logged in.
vision init # Interactive scaffolding + initial extension registration
cd my-extension
pnpm install
vision dev # Watch mode
Non-TTY/agent mode (Claude Code, CI) can run vision init without prompts:
vision init --name my-extension --description "My Vision extension" --targets editor,layer --yes
# Add --server to scaffold server files
# Add --link --extension-id <id> to link an existing extension non-interactively
Step 3: Build the UI
Use the SDK's 13 component primitives. Choose components based on what you need:
| Need | Component(s) |
|---|---|
| Wrap content with title | CompactView |
| Generic layout/grouping | Box |
| Vertical flex stack | VStack |
| Horizontal flex stack | HStack |
| Vertical list | List |
| Display text | Text |
| Display image | Image |
| Trigger an action | Button |
| Single-line input | TextField |
| Multi-line input | TextArea |
| Numeric input | NumberField |
| On/off switch | Toggle |
| Select from options | Dropdown |
Step 4: Add Persistence (if needed)
- Simple config/state: Use
useExtensionStorage(shared across targets, synced in real time) - Structured data: Add
server/schema.ts+server/functions.tsand useuseQuery/useMutation
Step 5: Deploy
vision deploy -v 1.0.0 -d "Initial release"
Architecture Overview
Vision extensions run in a three-layer sandbox architecture:
- Client sandbox -- Extensions render inside an invisible iframe. A custom React Reconciler serializes the component tree to JSON and sends it to the host via JSON-RPC over
postMessage. - Host renderer -- The host receives the serialized tree and renders real DOM elements using a component registry. The
targetparameter ("editor"/"layer"/"interactive") controls rendering behavior (e.g., buttons are hidden on"layer"). - Server runtime -- Server functions are declarative JSON (not JavaScript). The CLI extracts metadata at build time into a
RuntimeBundleManifest. This manifest is uploaded to S3, cryptographically signed (Ed25519), and executed inside Cloudflare Durable Objects with embedded SQLite.
Extensions do NOT have direct DOM access. They use the SDK's component primitives, which the host renders.
Extension Targets
Each extension can have up to three targets, each with a dedicated entry file:
| Target | Entry file | Output | Purpose |
|---|---|---|---|
editor |
src/admin.tsx |
dist/admin.js |
Dashboard settings panel for the streamer |
layer |
src/layer.tsx |
dist/layer.js |
OBS overlay rendered on stream |
interactive |
src/interactive.tsx |
dist/interactive.js |
Participant/control page (not high-scale public traffic) |
Project Structure
A Vision extension project looks like this:
my-extension/
vision.config.json # Project configuration
package.json
tsconfig.json
src/
admin.tsx # Editor panel (target: "editor")
layer.tsx # OBS overlay (target: "layer")
interactive.tsx # Audience page (target: "interactive")
server/ # Optional -- only if server functions are enabled
schema.ts # Database schema definition
functions.ts # Server function definitions
dist/ # Build output (gitignored)
vision.config.json
{
"extensionId": "clx...",
"name": "My Extension",
"description": "Does something cool",
"targets": ["editor", "layer", "interactive"],
"server": true,
"fonts": ["Inter", "Space Mono"]
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["src", "server"]
}
CLI Reference
The CLI is available as the vision command. All commands:
| Command | Description |
|---|---|
vision login |
Authenticate via browser OAuth flow |
vision logout |
Clear stored credentials |
vision whoami |
Show current authenticated user |
vision init |
Scaffold a new extension project |
vision init --name ... --description ... --targets ... --yes |
Non-interactive scaffolding (no TTY, agent/CI safe) |
vision init --link |
Interactive link flow for an existing extension |
vision init --link --extension-id <id> |
Link to an existing extension without prompts |
vision dev |
Watch mode: build, push schema, upload bundles on every change |
vision deploy |
Build and deploy a versioned release to production |
vision deploy -v 1.0.0 |
Deploy with a specific version tag |
vision deploy --submit-review |
Deploy and submit for review |
vision run <type:name> [args] |
Execute a server function (e.g. vision run query:getPlayers) |
vision env list |
List all secrets for the extension |
vision env set <KEY> <VALUE> |
Set a secret (e.g. vision env set TWITCH_CLIENT_ID xxx) |
vision env remove <KEY> |
Remove a secret |
vision dashboard |
Open the extension dashboard in the browser |
Development Workflow
vision login # Authenticate once
vision init # REQUIRED first run: scaffold project + register extension
cd my-extension
pnpm install
vision dev # Watch mode -- rebuilds and uploads on every save
Deployment Workflow
vision deploy -v 1.0.0 -d "Initial release" --submit-review
The deploy command: builds all bundles, creates a version, pushes the schema, uploads to S3, generates server bundle provenance (Ed25519 signed), activates the version as hosted, and optionally submits for review.
Extension Version Flow
local -> hosted -> review -> published
SDK Packages
@1upvision/sdk(^0.1.0) -- The main SDK. Import components, hooks, andVision.render().@1upvision/sdk/server-- Server-side exports. Import schema builders, function builders, validators, and declarative helpers.@1upvision/protocol(^0.1.0) -- Wire protocol types (internal, not typically imported directly).
Rendering
Every entry file must call Vision.render() exactly once:
import { Vision } from "@1upvision/sdk";
function MyComponent() {
return <Text content="Hello!" />;
}
Vision.render(<MyComponent />, { target: "layer" });
The target option ("editor" | "layer" | "interactive") is optional but recommended. It tells the host renderer which context the extension is running in.
Components
All components accept an optional style prop of type VisionStyle. Extensions build their UI exclusively from these 13 primitives:
Layout Components
Box
Generic container. Use for layout and grouping.
import { Box } from "@1upvision/sdk";
<Box style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{children}
</Box>;
Props:
style?: VisionStylechildren?: ReactNode
VStack / HStack
SwiftUI-style stack wrappers around Box.
VStack:display: "flex"+flexDirection: "column"HStack:display: "flex"+flexDirection: "row"
import { VStack, HStack } from "@1upvision/sdk";
<VStack spacing={12} align="stretch">
<HStack spacing={8} justify="space-between">{children}</HStack>
</VStack>;
Props:
spacing?: string | number(maps togap)align?: string(maps toalignItems)justify?: string(maps tojustifyContent)wrap?: string(maps toflexWrap)style?: VisionStylechildren?: ReactNode
CompactView
Container with an optional title header. Commonly used as a root wrapper.
import { CompactView } from "@1upvision/sdk";
<CompactView title="Settings">{children}</CompactView>;
Props:
title?: stringstyle?: VisionStylechildren?: ReactNode
List
Vertical list layout with configurable gap.
import { List } from "@1upvision/sdk";
<List gap={8}>
<Text content="Item 1" />
<Text content="Item 2" />
</List>;
Props:
gap?: numberstyle?: VisionStylechildren?: ReactNode
Display Components
Text
Renders text content with a variant.
import { Text } from "@1upvision/sdk";
<Text content="Hello World" variant="heading" />
<Text content="Some description" variant="muted" />
Props:
content: string(required)variant?: "default" | "muted" | "bold" | "heading"style?: VisionStyle
Image
Renders an image.
import { Image } from "@1upvision/sdk";
<Image
src="https://example.com/logo.png"
alt="Logo"
width={200}
height={100}
/>;
Props:
src: string(required)alt?: stringwidth?: numberheight?: numberstyle?: VisionStyle
Input Components
Button
Clickable button. Hidden on the "layer" target (OBS overlays).
import { Button } from "@1upvision/sdk";
<Button
label="Save"
variant="default"
onClick={() => console.log("clicked")}
/>;
Props:
label: string(required)variant?: "default" | "secondary" | "outline" | "destructive"disabled?: booleanonClick?: () => voidstyle?: VisionStyle
TextField
Single-line text input.
import { TextField } from "@1upvision/sdk";
<TextField
label="Username"
placeholder="Enter name..."
value={name}
onChange={(value) => setName(value)}
/>;
Props:
label?: stringplaceholder?: stringvalue?: stringonChange?: (value: string) => voiddisabled?: booleanstyle?: VisionStyle
TextArea
Multi-line text input.
import { TextArea } from "@1upvision/sdk";
<TextArea
label="Bio"
placeholder="Tell us about yourself..."
value={bio}
onChange={(value) => setBio(value)}
rows={4}
/>;
Props:
label?: stringplaceholder?: stringvalue?: stringonChange?: (value: string) => voidrows?: numberdisabled?: booleanstyle?: VisionStyle
NumberField
Numeric input with min/max/step.
import { NumberField } from "@1upvision/sdk";
<NumberField
label="Score"
value={score}
min={0}
max={100}
step={1}
onChange={(value) => setScore(value)}
/>;
Props:
label?: stringvalue?: numbermin?: numbermax?: numberstep?: numberonChange?: (value: number) => voiddisabled?: booleanstyle?: VisionStyle
Toggle
Boolean toggle switch.
import { Toggle } from "@1upvision/sdk";
<Toggle
label="Enabled"
checked={enabled}
onChange={(checked) => setEnabled(checked)}
/>;
Props:
label?: stringchecked?: booleanonChange?: (checked: boolean) => voiddisabled?: booleanstyle?: VisionStyle
Dropdown
Select dropdown.
import { Dropdown } from "@1upvision/sdk";
<Dropdown
label="Theme"
value={theme}
options={[
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
onChange={(value) => setTheme(value)}
/>;
Props:
label?: stringvalue?: stringoptions: Array<{ label: string; value: string }>(required)onChange?: (value: string) => voiddisabled?: booleanstyle?: VisionStyle
VisionStyle
The style prop on every component accepts a safe subset of CSS properties. Position is restricted to "relative" or "absolute" only. Dangerous CSS patterns (expressions, urls in non-image contexts, etc.) are blocked by a runtime sanitizer.
Supported style categories
- Layout:
display,flexDirection,flexWrap,alignItems,justifyContent,alignSelf,gap,flex,flexGrow,flexShrink,flexBasis,gridTemplateColumns,gridTemplateRows,gridColumn,gridRow,gridGap - Position:
position("relative"|"absolute"only),top,right,bottom,left,zIndex - Sizing:
width,height,minWidth,maxWidth,minHeight,maxHeight,aspectRatio - Spacing:
padding,paddingTop,paddingRight,paddingBottom,paddingLeft,margin,marginTop,marginRight,marginBottom,marginLeft - Colors:
color,backgroundColor,background,opacity - Border:
border,borderWidth,borderColor,borderStyle,borderRadius,borderTop,borderRight,borderBottom,borderLeft - Typography:
fontSize,fontWeight,fontFamily,fontStyle,textAlign,textDecoration,textTransform,lineHeight,letterSpacing,whiteSpace,overflow,textOverflow,wordBreak - Transform:
transform,transformOrigin,transition - Effects:
boxShadow,textShadow,filter,backdropFilter - Other:
objectFit,pointerEvents,userSelect,cursor,overflowX,overflowY
Hooks
useExtensionStorage
Persistent key-value storage shared across all targets of the same extension installation. Changes from one target (e.g., editor) are pushed to other targets (e.g., layer) in real time via the host bridge.
import { useExtensionStorage } from "@1upvision/sdk";
function MyComponent() {
const [storage, setStorage] = useExtensionStorage();
return (
<Toggle
label="Enabled"
checked={(storage.enabled as boolean) ?? false}
onChange={(checked) => setStorage({ ...storage, enabled: checked })}
/>
);
}
Returns: [Record<string, unknown>, (data: Record<string, unknown>) => void]
- First element: current storage state
- Second element: setter function (replaces entire storage object)
useVisionState
Key-scoped shared state backed by extension storage. This gives useState-style
updates without manually replacing the full storage object.
import { useVisionState } from "@1upvision/sdk";
function Counter() {
const [count, setCount] = useVisionState("count", 0);
return (
<Button
label={`Count: ${count}`}
onClick={() => setCount((prev) => prev + 1)}
/>
);
}
Signature:
useVisionState<T>(
key: string,
initialValue: T,
): [T, (next: T | ((previous: T) => T)) => void];
- Uses per-key patch updates under the hood (
storage.patch), so unrelated storage keys are preserved. - Prefer this for single values/counters/flags.
- Use
useExtensionStoragewhen you intentionally manage the full object.
useExtensionContext
Access extension metadata: extensionId, target, overlayId, and
interactiveUrl.
import { useExtensionContext } from "@1upvision/sdk";
function MyComponent() {
const context = useExtensionContext();
// context?.extensionId, context?.target, context?.overlayId,
// context?.interactiveUrl
}
Returns: ExtensionContext | null
interface ExtensionContext {
extensionId: string;
target: "editor" | "layer" | "interactive";
overlayId: string;
interactiveUrl?: string;
}
Interactive link example:
const context = useExtensionContext();
let joinUrl = "";
if (context?.interactiveUrl) {
const url = new URL(context.interactiveUrl);
url.searchParams.set("player", "1");
joinUrl = url.toString();
}
useQuery
Call a server query function by name. Automatically refetches when useMutation succeeds (query invalidation).
Use useQuery for read/query functions only (queryRows, query). Do not use it for fetchAction.
import { useQuery } from "@1upvision/sdk";
function PlayerList() {
const { data: players, isLoading, error, refetch } = useQuery("getPlayers");
const { data: player } = useQuery("getPlayer", { userId: "alice" });
}
Signature: useQuery<T>(name: string, args?: Record<string, unknown>): UseQueryResult<T>
interface UseQueryResult<T> {
data: T | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
useMutation
Call a server mutation or action function by name. Triggers automatic query invalidation on success.
Use useMutation for write/action functions (insertRow, patchRow, deleteRow, fetchAction, mutation, action).
For actions, prefer explicit names like useMutation("action:getLiveScores") to avoid query/mutation mixups.
import { useMutation } from "@1upvision/sdk";
function AddPlayer() {
const { mutate, mutateAsync, isLoading, error } = useMutation("addPlayer");
// Fire-and-forget:
mutate({ userId: "alice", score: 100 });
// Async with error handling:
const result = await mutateAsync({ userId: "bob", score: 200 });
}
Signature: useMutation<T>(name: string): UseMutationResult<T>
interface UseMutationResult<T> {
mutate: (args?: Record<string, unknown>) => void;
mutateAsync: (args?: Record<string, unknown>) => Promise<T>;
data: T | undefined;
isLoading: boolean;
error: Error | null;
}
Server Functions
Server functions run on Cloudflare Durable Objects with embedded SQLite. They are defined in the server/ directory and are purely declarative -- no JavaScript ships to the runtime. The CLI extracts metadata at build time.
All server imports come from @1upvision/sdk/server.
Schema Definition (server/schema.ts)
Define your database tables with typed fields and indexes. The file must export default a defineSchema() call.
import { defineSchema, defineTable, v } from "@1upvision/sdk/server";
export default defineSchema({
players: defineTable({
userId: v.string(),
score: v.number(),
active: v.boolean(),
}).index("by_score", ["score"]),
messages: defineTable({
text: v.string(),
author: v.string(),
timestamp: v.number(),
}),
});
Each table automatically gets an id field (string). Indexes enable efficient querying.
Storage Scopes
Tables support a storageScope that controls data partitioning:
| Scope | Partition Key | Description |
|---|---|---|
"instance" |
layerId (fallback: installationId) |
Data is isolated per extension layer instance |
"overlay" |
overlayId (fallback: installationId) |
Data is shared across extension layers in the same overlay |
"account" |
channelId |
Data shared across all overlays for the same channel (default) |
"global" |
extensionId |
Data shared across ALL installs of this extension |
Set via options object or chainable method:
defineTable({ key: v.string() }, { storage: "global" });
defineTable({ key: v.string() }).storage("overlay");
// Default is "account" — no need to specify
Runtime Admin Scope Selectors
In the Extensions admin runtime UI (Data + Functions tabs), scoped tables/functions require selecting the scope target:
accountscope: select account/channel installationoverlayscope: select account + overlayinstancescope: select account + overlay + layerglobalscope: no selector needed
Functions that target tables inherit scope from the selected table metadata.
Validators (v)
The v validator builder provides runtime type checking for function arguments and schema fields:
| Validator | Type | Example |
|---|---|---|
v.string() |
string |
v.string() |
v.number() |
number (finite) |
v.number() |
v.boolean() |
boolean |
v.boolean() |
v.literal(value) |
Exact value | v.literal("active") |
v.any() |
unknown |
v.any() |
v.nullable(validator) |
T | null |
v.nullable(v.string()) |
v.optional(validator) |
T | undefined |
v.optional(v.number()) |
v.array(validator) |
T[] |
v.array(v.string()) |
v.object(shape) |
Typed object | v.object({ name: v.string() }) |
v.union(...validators) |
Union type | v.union(v.string(), v.number()) |
Any validator can be made optional: v.string().optional().
Declarative Functions (server/functions.ts)
The preferred way to define server functions. These produce JSON-serializable definitions that the Cloudflare runtime executes directly.
Client Hook Mapping (Critical)
Always call declarative functions with the correct hook:
| Server function definition | Call via |
|---|---|
queryRows(...) |
useQuery |
insertRow(...), patchRow(...), deleteRow(...) |
useMutation |
fetchAction(...) |
useMutation |
action({...}) |
useMutation |
If a function is declared as fetchAction, calling it via useQuery can produce FUNCTION_NOT_FOUND at runtime.
For action invocations, both useMutation("getLiveScores") and useMutation("action:getLiveScores") work, but the prefixed form is recommended for clarity.
There is no useAction hook. Actions are invoked via useMutation("functionName").
queryRows -- Read from a table
import { queryRows, filter, arg } from "@1upvision/sdk/server";
// Get all players
export const getPlayers = queryRows("players");
// Get players with a filter
export const getPlayersByUser = queryRows("players", {
filters: [filter("userId", "eq", arg("userId"))],
limit: 50,
});
Options: filters?: FilterDefinition[], limit?: number, offset?: number, scope?: string | string[]
insertRow -- Create a row
import { insertRow, arg } from "@1upvision/sdk/server";
export const addPlayer = insertRow("players", {
userId: arg("userId"),
score: arg("score"),
});
Options: id?: ValueExpression, scope?: string | string[]
patchRow -- Update a row
import { patchRow, arg } from "@1upvision/sdk/server";
export const updateScore = patchRow("players", arg("playerId"), {
score: arg("newScore"),
});
deleteRow -- Delete a row
import { deleteRow, arg } from "@1upvision/sdk/server";
export const removePlayer = deleteRow("players", arg("playerId"));
fetchAction -- Outbound HTTP request
import { fetchAction, secret } from "@1upvision/sdk/server";
export const getWeather = fetchAction(
{
url: "https://api.weather.com/current",
headers: { "X-Api-Key": secret("WEATHER_API_KEY") },
},
{ response: "json" },
);
Request config: url: ValueExpression, method?: ValueExpression, headers?: Record<string, ValueExpression>, body?: ValueExpression
Options: response?: "json" | "text" | "none", scope?: string | string[]
Action Egress Allowlist (vision.config.json)
fetchAction URLs are restricted by the server runtime egress allowlist. Configure allowed hosts/ports in vision.config.json:
{
"name": "my-extension",
"description": "...",
"targets": ["editor", "layer"],
"server": true,
"egress": {
"allowHosts": ["site.api.espn.com", "*.example.com"],
"allowPorts": [443]
}
}
After changing egress config, rebuild/redeploy (vision dev or vision deploy) so the new server bundle includes updated egress rules.
Call fetchAction functions from the client with useMutation:
import { Button, useMutation } from "@1upvision/sdk";
function ScoresButton() {
const { mutateAsync: getLiveScores } = useMutation("getLiveScores");
const onClick = async () => {
const data = await getLiveScores({
url: "https://site.api.espn.com/apis/site/v2/sports/soccer/eng.1/scoreboard",
});
console.log(data);
};
return <Button label="Refresh" onClick={onClick} />;
}
Value Expressions
Dynamic values referenced at runtime:
| Helper | Wire format | Description |
|---|---|---|
arg("name") |
{ $arg: "name" } |
Reference a function argument |
identity("subject") |
{ $identity: "subject" } |
Reference the caller's identity field |
secret("KEY") |
{ $secret: "KEY" } |
Reference a stored secret (set via vision env set) |
"literal" |
"literal" |
Literal JSON value |
Filter Syntax
import { filter, arg, identity } from "@1upvision/sdk/server";
filter("userId", "eq", arg("userId")); // Field equals an argument
filter("owner", "eq", identity("subject")); // Field equals caller's identity
filter("score", "gt", 100); // Field greater than literal
Operators: "eq", "neq", "lt", "lte", "gt", "gte", "in"
Handler-Based Functions (Advanced)
For complex logic that cannot be expressed declaratively, use query(), mutation(), and action(). Note: these still require a declarative kind to be executable by the runtime engine.
import { query, mutation, action, v } from "@1upvision/sdk/server";
export const getPlayers = query({
args: { limit: v.number().optional() },
scope: "read",
async handler(ctx, args) {
return await ctx.db.query("players", { limit: args.limit ?? 50 });
},
});
export const addPlayer = mutation({
args: { userId: v.string(), score: v.number() },
async handler(ctx, args) {
return await ctx.db.insert("players", {
userId: args.userId,
score: args.score,
});
},
});
export const fetchExternalData = action({
args: { endpoint: v.string() },
async handler(ctx, args) {
const apiKey = await ctx.secrets.get("API_KEY");
const response = await ctx.fetch(args.endpoint, {
headers: { Authorization: `Bearer ${apiKey}` },
});
return await response.json();
},
});
Function Types and Contexts
| Type | Context | DB Access | HTTP Fetch | Secrets |
|---|---|---|---|---|
query |
QueryContext |
Read-only (db.get, db.query) |
No | No |
mutation |
MutationContext |
Read-write (db.get, db.query, db.insert, db.patch, db.delete) |
No | No |
action |
ActionContext |
Read-write | Yes (ctx.fetch) |
Yes (ctx.secrets.get) |
Auth API
See auth.md for full runtime authorization defaults, caller identity fields, and restriction patterns.
All contexts include ctx.auth:
const identity = await ctx.auth.getUserIdentity();
// { subject: string, issuer?: string, audience?: string, ... }
const canRead = ctx.auth.hasScope("read");
Runtime Authorization Defaults (Current)
Server functions are not public endpoints, but by default they are callable by channel users/delegates with extension permissions.
- Platform gate for
query: caller needsextensions.read(orextensions.write/administrator) - Platform gate for
mutationandaction: caller needsextensions.write(oradministrator) - Default function-level behavior:
editorOnlyisfalse(no extra restriction) - If
editorOnly: true, only verified editors of the channel and the channel owner can invoke the function
Use editorOnly: true on write-sensitive functions when you need stricter control.
scope can still be used for custom auth checks in handler-based code (ctx.auth.hasScope(...)), but for declarative runtime execution today it should not be treated as the primary enforcement boundary.
Example with explicit function-level restriction:
export const adminOnly = query({
editorOnly: true,
handler(ctx) {
/* ... */
},
});
Secrets Management
Secrets are encrypted with AES-GCM-256 and stored server-side. Manage them with the CLI:
vision env set TWITCH_CLIENT_ID your-client-id
vision env set WEATHER_API_KEY your-api-key
vision env list
vision env remove TWITCH_CLIENT_ID
Access them in server functions via secret("KEY") in declarative functions or ctx.secrets.get("KEY") in action handlers.
Building Overlay UIs
OBS overlay extensions (target: "layer") render directly onto the stream. Key considerations:
Conditional Rendering
Use useExtensionStorage to toggle visibility based on editor settings:
import { Vision, Box, Text, useExtensionStorage } from "@1upvision/sdk";
function Overlay() {
const [storage] = useExtensionStorage();
if (!storage.enabled) return null;
return (
<Box
style={{
position: "absolute",
top: 20,
right: 20,
backgroundColor: "rgba(0, 0, 0, 0.8)",
borderRadius: 12,
padding: 16,
}}
>
<Text
content={(storage.displayText as string) ?? "Hello!"}
variant="heading"
style={{ color: "#ffffff", fontSize: 24 }}
/>
</Box>
);
}
Vision.render(<Overlay />, { target: "layer" });
Overlay Positioning
Use position: "absolute" on Box for precise placement within the OBS source bounds:
<Box style={{ position: "absolute", bottom: 10, left: 10 }}>
{/* Bottom-left overlay content */}
</Box>
Overlay Styling Tips
- Use
backgroundColor: "rgba(...)"for semi-transparent backgrounds - Use
borderRadiusfor rounded corners - Set explicit
width/heightor use flexbox for layout - Use
coloronTextstyle for text color - Buttons and interactive inputs are hidden on the
"layer"target -- use"editor"or"interactive"for controls - Use
transitionfor smooth style changes - Keep overlays performant -- avoid deep nesting
Editor + Layer Pattern
The standard pattern: use "editor" to configure, "layer" to display.
src/admin.tsx (editor):
import {
Vision,
CompactView,
Text,
TextField,
Toggle,
useExtensionStorage,
} from "@1upvision/sdk";
function Settings() {
const [storage, setStorage] = useExtensionStorage();
return (
<CompactView title="My Overlay">
<Toggle
label="Show overlay"
checked={(storage.enabled as boolean) ?? false}
onChange={(checked) => setStorage({ ...storage, enabled: checked })}
/>
<TextField
label="Display text"
value={(storage.text as string) ?? ""}
onChange={(value) => setStorage({ ...storage, text: value })}
/>
</CompactView>
);
}
Vision.render(<Settings />, { target: "editor" });
src/layer.tsx (overlay):
import { Vision, Box, Text, useExtensionStorage } from "@1upvision/sdk";
function Overlay() {
const [storage] = useExtensionStorage();
if (!storage.enabled) return null;
return (
<Box
style={{
padding: 16,
backgroundColor: "rgba(0,0,0,0.7)",
borderRadius: 8,
}}
>
<Text
content={(storage.text as string) ?? "Hello!"}
style={{ color: "#fff", fontSize: 20 }}
/>
</Box>
);
}
Vision.render(<Overlay />, { target: "layer" });
Full-Stack Extension with Server Functions
Combine all three targets with server-side data:
server/schema.ts:
import { defineSchema, defineTable, v } from "@1upvision/sdk/server";
export default defineSchema({
scores: defineTable({
playerName: v.string(),
points: v.number(),
}).index("by_points", ["points"]),
});
server/functions.ts:
import {
queryRows,
insertRow,
deleteRow,
arg,
filter,
} from "@1upvision/sdk/server";
export const getScores = queryRows("scores", { limit: 10 });
export const addScore = insertRow("scores", {
playerName: arg("playerName"),
points: arg("points"),
});
export const removeScore = deleteRow("scores", arg("id"));
src/admin.tsx (manage scores):
import {
Vision,
CompactView,
Text,
TextField,
NumberField,
Button,
List,
useQuery,
useMutation,
} from "@1upvision/sdk";
import { useState } from "react";
function ScoreManager() {
const { data: scores, isLoading } = useQuery("getScores");
const { mutate: addScore } = useMutation("addScore");
const { mutate: removeScore } = useMutation("removeScore");
const [name, setName] = useState("");
const [points, setPoints] = useState(0);
return (
<CompactView title="Score Manager">
<TextField label="Player" value={name} onChange={setName} />
<NumberField label="Points" value={points} onChange={setPoints} />
<Button
label="Add"
onClick={() => {
addScore({ playerName: name, points });
setName("");
setPoints(0);
}}
/>
<List gap={4}>
{((scores as any[]) ?? []).map((s: any) => (
<Box
key={s.id}
style={{ display: "flex", justifyContent: "space-between" }}
>
<Text content={`${s.playerName}: ${s.points}`} />
<Button
label="Remove"
variant="destructive"
onClick={() => removeScore({ id: s.id })}
/>
</Box>
))}
</List>
</CompactView>
);
}
Vision.render(<ScoreManager />, { target: "editor" });
src/layer.tsx (display scores on stream):
import { Vision, Box, Text, List, useQuery } from "@1upvision/sdk";
function Leaderboard() {
const { data: scores } = useQuery("getScores");
return (
<Box
style={{
padding: 16,
backgroundColor: "rgba(0,0,0,0.85)",
borderRadius: 12,
minWidth: 200,
}}
>
<Text
content="Leaderboard"
variant="heading"
style={{ color: "#fff", marginBottom: 8 }}
/>
<List gap={4}>
{((scores as any[]) ?? []).map((s: any, i: number) => (
<Text
key={s.id}
content={`${i + 1}. ${s.playerName} - ${s.points}`}
style={{ color: "#e0e0e0", fontSize: 14 }}
/>
))}
</List>
</Box>
);
}
Vision.render(<Leaderboard />, { target: "layer" });
Build System
Extensions are built with esbuild. The CLI handles building automatically during vision dev and vision deploy.
Client bundles: Format iife, platform browser, target es2022. The SDK (@1upvision/sdk), React, and react/jsx-runtime are externalized (provided by the Vision runtime).
Server bundles: The CLI evaluates server/schema.ts and server/functions.ts at build time using esbuild + dynamic import, extracts metadata into a RuntimeBundleManifest JSON file at dist/server.bundle.js.
Manual build (without the CLI)
esbuild src/admin.tsx --bundle --outfile=dist/admin.js --format=iife --jsx=automatic --external:@1upvision/sdk --external:react --external:react/jsx-runtime
Important Constraints
- No direct DOM access. Extensions run in a sandboxed iframe. All UI must be built with the 13 SDK components.
- No arbitrary JavaScript on the server. Server functions must be declarative. The CLI extracts JSON metadata -- handlers are for type safety and local testing only.
- Buttons and interactive inputs are hidden on
"layer"target. Put controls in"editor"or"interactive". - Interactive pages are not a high-scale public web surface. Use them for participant/control workflows, not thousands of concurrent public viewers.
- Position is restricted to
"relative"or"absolute". Fixed and sticky positioning is not allowed. - Style sanitization is enforced. Dangerous CSS patterns (expressions, javascript:, etc.) are blocked.
- Font loading should use supported APIs. Use
vision.config.json.fontsfor Google fonts andlocalFont()for local files inpublic/; don't rely onurl(...)in component style. useVisionStateis available for key-scoped shared state withuseState-style updates.useExtensionStorageis per-installation. Host extension storage is isolated per installation. Server table data can be broader viastorageScope(instance/overlay/account/global).- React 18 or 19 required as a peer dependency.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Vision SDK: Bridge not initialized |
Vision.render() called before sandbox bootstrap |
Ensure Vision.render() is called at the top level of the entry file, not inside useEffect or async code |
| Buttons not showing on overlay | Buttons are hidden on "layer" target by design |
Move buttons to "editor" or "interactive" target |
| Storage changes not syncing | setStorage replaces the entire object |
Always spread existing storage: setStorage({ ...storage, key: value }) |
No vision.config.json found |
CLI cannot find config file | Run vision init or ensure you're in the project directory |
No extensionId in vision.config.json |
Extension not registered on server | Run vision dev once to auto-register, or add extensionId manually |
| Server function not executing | Function lacks a declarative kind |
Use declarative builders (queryRows, insertRow, etc.) instead of handler-only functions |
FUNCTION_NOT_FOUND for an existing function |
Hook/type mismatch (e.g. fetchAction called with useQuery) |
Use the hook mapping: queryRows → useQuery; insert/patch/delete/fetchAction → useMutation |
Action fetch host is not on allowlist |
Host not listed in vision.config.json egress allowlist |
Add host to egress.allowHosts (and port to egress.allowPorts if needed), then run vision dev/deploy |
| Schema push failed | server/schema.ts doesn't export default a defineSchema() call |
Ensure the file has export default defineSchema({ ... }) |
| Style property ignored | Property not in the VisionStyle allowlist |
Check the supported style categories; position: "fixed" and position: "sticky" are blocked |
useQuery not refetching after mutation |
Query invalidation requires useMutation from the SDK |
Use useMutation (not manual bridge calls) so invalidation triggers automatically |