SDK reference

@velvetrituals/toys (web, v0.1). Strength is always 0–100. Every command has a duration and stops on its own.

VelvetToys.init(options)

const velvet = await VelvetToys.init({
  apiKey: 'vr_pk_test_…',     // publishable key; a secret key is refused
  backgroundPlayback: false,  // true = keep playing while the page is hidden
  baseUrl: undefined,         // gateway override, e.g. the dev gateway
  debug: false,               // log protocol traffic to the console
});

Verifies the key and returns a client. It throws a VelvetToysError if the key is invalid, revoked or expired, the origin isn't allowed, or the app is suspended. Only active results are cached, for at most an hour, and only used when the network fails.

PropertyMeaning
velvet.appIdYour app's ID
velvet.environment'test' or 'live'
velvet.limits{ maxStrength, maxDurationMs }. Every command is clamped to these
velvet.connectedToysToys connected through this client

velvet.connect(options?)

const toy = await velvet.connect();                                   // Bluetooth picker
const sim = await velvet.connect({ virtual: true, model: 'Eclipse' }); // virtual toy

Call it from a click handler: the browser only opens its picker on a user gesture. The key is checked again on every call. Picking a toy that's already connected returns the same Toy.

velvet.destroy() stops and disconnects every toy.

Toy commands

CallRules
toy.vibrate({ strength, durationMs, motor? })strength 0–100. durationMs 100–60,000, required. motor 1 (outer), 2 (insertable) or 'all' (default); 1 and 2 only on dual-motor toys
toy.preset({ name, strength, durationMs })A built-in firmware mode (below); strength and duration as for vibrate
toy.pattern({ steps, loop?, durationMs, motor? })Up to 50 steps of { strength, ms }, each at least 100 ms. With loop, repeats until durationMs
toy.playTrack(track, { media?, offsetMs?, motor? })Plays a strength curve, following a <video> or <audio> if given. Up to 4 h, stopped by a watchdog if sampling stalls. See Sync to video and audio
toy.heat({ level, durationMs })Heating toys only. level 'off', 'low' or 'high'; up to 600,000 ms (10 min), then off
toy.suction({ strength, durationMs })Suction toys only (Aurora). strength 0–100; up to 60,000 ms, then off. Aurora's motor makes the suction, so this and vibrate() drive the same thing
toy.stop()Stops motors, heat and suction. Always allowed
toy.battery()Battery %, 0–100
toy.disconnect()Sends stop, then disconnects

The latest motor command replaces the one playing. Heat runs on its own timer.

  • Aurora: suction comes from its motor. suction(), vibrate(), presets, patterns and tracks all drive it, and the latest call wins. Patterns feel like pulsing suction. It runs at 50 % or more: any strength above 0 is scaled into 50–100 % (toy.info.minStrength is 50), and 0 is off.
  • Eclipse: motor 1 is the outer (clitoral) part, motor 2 the insertable part. Switching between motor 1, motor 2 and both takes about 0.3 s: the toy needs a stop in between, and the SDK sends it for you. Changing strength on the same motor is instant.
▶ vibrate ▶ pattern ▶ two motors (Eclipse) ▶ heat (Ember) ▶ suction (Aurora)

Calling a capability the toy doesn't have throws unsupported_capability. Check first with toy.info:

toy.info; // { model: 'Eclipse', modelId: 10, motors: 2, heating: false, suction: false, minStrength: 0 }

if (toy.info.heating) await toy.heat({ level: 'low', durationMs: 300000 });

The 13 models come in four kinds: vibration, dual motor, suction and heat. Show controls from toy.info, not from the model name, and your app works with every model. Before connecting, modelInfo(model) answers the same for a model name.

Presets

constant, slow-pulses, fast-pulses, wave, soar, step-up, random, rhythm, double-wave, knocking, burst, rolling-wave.

Lovense-style aliases also work: pulse (fast-pulses), wave, fireworks (burst), earthquake (rolling-wave).

▶ Try the presets

For ready-made pattern() recipes (tease, wave, heartbeat, build-up and more), see the pattern cookbook.

await toy.preset({ name: 'wave', strength: 60, durationMs: 10000 });
await toy.pattern({
  steps: [{ strength: 20, ms: 300 }, { strength: 90, ms: 300 }],
  loop: true,
  durationMs: 15000,
});

Events

toy.on('disconnect', (reason) => {});  // 'user' or 'lost'; call connect() again to reconnect
toy.on('battery', (pct) => {});        // the toy reports battery on its own every few seconds
toy.on('state', (s) => {});            // { motor1, motor2, preset, heat, suction }, from frames actually sent

toy.on() returns an unsubscribe function.

Virtual toy

connect({ virtual: true, model }) gives you a simulated toy with the same API, limits and timers, and no Bluetooth. It still needs a valid key. mountVirtualToyWidget(element, toy) renders a live view of its state (it works for real toys too) and returns an unmount function.

Errors

Every failure is a VelvetToysError with a code:

CodeWhen
invalid_keyUnknown, revoked or expired key, or a secret key
origin_not_allowedThis origin isn't on the app's allowlist
app_suspendedThe app is suspended
rate_limited, network_error, developer_api_unavailableThe key check couldn't complete
bluetooth_unavailableNo Web Bluetooth in this browser (support)
cancelledThe player closed the picker
unsupported_toy, connection_failed, not_connectedToy problems
unsupported_capabilityThe toy lacks heat, suction or a second motor
invalid_argumentA value out of range (e.g. durationMs missing)
page_hiddenA command while the page is hidden, without backgroundPlayback

Iframes

The SDK works inside a cross-origin iframe on Chromium if the parent allows Bluetooth:

<iframe src="https://your-app.example" allow="bluetooth"></iframe>

The picker must open from a click inside the iframe. The key check uses the iframe's own origin.

React

The SDK has no React dependency. Keep one client per page, created before any click so connect() keeps the user gesture, and destroy it on unmount (that stops the toy):

'use client';

import { VelvetToys, type Toy } from '@velvetrituals/toys';
import { useEffect, useRef, useState } from 'react';

export function useVelvetToy(apiKey: string) {
  const velvet = useRef<VelvetToys | null>(null);
  const [toy, setToy] = useState<Toy | null>(null);

  useEffect(() => {
    VelvetToys.init({ apiKey }).then((v) => (velvet.current = v));
    return () => void velvet.current?.destroy();
  }, [apiKey]);

  const connect = async () => setToy(await velvet.current!.connect()); // call from onClick
  return { toy, connect };
}

React Native

@velvetrituals/toys-react-native follows soon, with the same calls on react-native-ble-plx. It needs an Expo dev build or bare React Native (not Expo Go).

Last updated 18 Oct 2026