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.
| Property | Meaning |
|---|---|
velvet.appId | Your app's ID |
velvet.environment | 'test' or 'live' |
velvet.limits | { maxStrength, maxDurationMs }. Every command is clamped to these |
velvet.connectedToys | Toys 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
| Call | Rules |
|---|---|
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.minStrengthis 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.
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).
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:
| Code | When |
|---|---|
invalid_key | Unknown, revoked or expired key, or a secret key |
origin_not_allowed | This origin isn't on the app's allowlist |
app_suspended | The app is suspended |
rate_limited, network_error, developer_api_unavailable | The key check couldn't complete |
bluetooth_unavailable | No Web Bluetooth in this browser (support) |
cancelled | The player closed the picker |
unsupported_toy, connection_failed, not_connected | Toy problems |
unsupported_capability | The toy lacks heat, suction or a second motor |
invalid_argument | A value out of range (e.g. durationMs missing) |
page_hidden | A 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).