vision-extension-development

star 4

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.

1upmanagement By 1upmanagement schedule Updated 3/4/2026

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 (useQuery vs useMutation), and action invocation patterns
  • runtime-scopes.md -- table storage scopes (instance/overlay/account/global) and runtime selector behavior
  • components.md -- SDK component primitives and style constraints
  • cli-workflows.md -- vision dev/vision deploy, egress config, and release flow
  • best-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.json fonts, localFont, local font assets in public/)
  • Needs to define server-side schema, functions, or secrets (server/schema.ts, server/functions.ts)
  • Works with vision.config.json or 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.ts and use useQuery / useMutation

Step 5: Deploy

vision deploy -v 1.0.0 -d "Initial release"

Architecture Overview

Vision extensions run in a three-layer sandbox architecture:

  1. 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.
  2. Host renderer -- The host receives the serialized tree and renders real DOM elements using a component registry. The target parameter ("editor" / "layer" / "interactive") controls rendering behavior (e.g., buttons are hidden on "layer").
  3. 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, and Vision.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?: VisionStyle
  • children?: 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 to gap)
  • align?: string (maps to alignItems)
  • justify?: string (maps to justifyContent)
  • wrap?: string (maps to flexWrap)
  • style?: VisionStyle
  • children?: 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?: string
  • style?: VisionStyle
  • children?: 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?: number
  • style?: VisionStyle
  • children?: 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?: string
  • width?: number
  • height?: number
  • style?: 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?: boolean
  • onClick?: () => void
  • style?: 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?: string
  • placeholder?: string
  • value?: string
  • onChange?: (value: string) => void
  • disabled?: boolean
  • style?: 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?: string
  • placeholder?: string
  • value?: string
  • onChange?: (value: string) => void
  • rows?: number
  • disabled?: boolean
  • style?: 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?: string
  • value?: number
  • min?: number
  • max?: number
  • step?: number
  • onChange?: (value: number) => void
  • disabled?: boolean
  • style?: VisionStyle

Toggle

Boolean toggle switch.

import { Toggle } from "@1upvision/sdk";

<Toggle
  label="Enabled"
  checked={enabled}
  onChange={(checked) => setEnabled(checked)}
/>;

Props:

  • label?: string
  • checked?: boolean
  • onChange?: (checked: boolean) => void
  • disabled?: boolean
  • style?: 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?: string
  • value?: string
  • options: Array<{ label: string; value: string }> (required)
  • onChange?: (value: string) => void
  • disabled?: boolean
  • style?: 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 useExtensionStorage when 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:

  • account scope: select account/channel installation
  • overlay scope: select account + overlay
  • instance scope: select account + overlay + layer
  • global scope: 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 needs extensions.read (or extensions.write / administrator)
  • Platform gate for mutation and action: caller needs extensions.write (or administrator)
  • Default function-level behavior: editorOnly is false (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 borderRadius for rounded corners
  • Set explicit width/height or use flexbox for layout
  • Use color on Text style for text color
  • Buttons and interactive inputs are hidden on the "layer" target -- use "editor" or "interactive" for controls
  • Use transition for 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.fonts for Google fonts and localFont() for local files in public/; don't rely on url(...) in component style.
  • useVisionState is available for key-scoped shared state with useState-style updates.
  • useExtensionStorage is per-installation. Host extension storage is isolated per installation. Server table data can be broader via storageScope (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: queryRowsuseQuery; insert/patch/delete/fetchActionuseMutation
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
Install via CLI
npx skills add https://github.com/1upmanagement/vision-extensions --skill vision-extension-development
Repository Details
star Stars 4
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
1upmanagement
1upmanagement Explore all skills →