Drop SDK
Every Droplet auto-loads a JavaScript API as `window.Drop` — read other Droplets, write typed fields, run polls and surveys, persist state.
Every Droplet auto-loads a JavaScript API at window.Drop. From inside your HTML you can read other Droplets, write to your own typed fields, identify the current viewer, run polls and surveys, and persist state between sessions. There is no install step and no <script> tag to add — the SDK is already there when your code runs.
Quickstart
Wait for the SDK to bootstrap, then call methods. Anything beyond the State API needs a capability declaration in the <head> (see Capabilities):
<!DOCTYPE html>
<html>
<head>
<meta name="drop-sdk-capabilities" content="viewer:read">
</head>
<body>
<h1></h1>
<script>
await Drop.ready;
const me = await Drop.viewer.me();
document.querySelector('h1').textContent = `Hello, ${me.name}!`;
</script>
</body>
</html>
Surfaces
| Surface | What you can do |
|---|---|
Drop.viewer | Get the current viewer's name, email, and Space memberships. |
Drop.droplets | List or fetch other Droplets the viewer can read; optionally fold in their shared state for aggregation. |
Drop.types | Read the Type registry — every Type and its field schema. |
Drop.fields | Read typed field values; write to your own Droplet. |
Drop.state | Per-viewer key-value storage. Other viewers never see your values. |
Drop.shared | One value per Droplet, visible to everyone. Read your own or, with state:read, another Droplet's. Optimistic concurrency built in. |
Drop.poll | One vote per viewer with aggregated counts. |
Drop.survey | Structured submissions, one per viewer, overwrite on resubmit. |
Drop.access | Names and emails of everyone with read access. |
The operations table below has signatures and short examples for each.
Calls run as the viewer
Every SDK call is evaluated against the current viewer, not against you. Drop.droplets.list() returns the subset of Droplets the viewer can read; Drop.fields.set() succeeds only if the viewer would be allowed to edit that field from the UI. The author of a Droplet has no runtime presence — it is just HTML someone uploaded.
In practice this means two things:
- You don't write permission checks in Droplet code. If a viewer should not see a row, they don't see it.
- If a Droplet you expect to load is missing for some viewer, check whether they have access to it — not whether the SDK is misconfigured.
Capabilities
Meta-SDK methods require an opt-in token in the Droplet's HTML head:
<meta name="drop-sdk-capabilities" content="droplets:read fields:write">
Multiple tokens are space-separated. Without the right token, the call rejects with capability_not_declared before any request leaves the iframe — so the failure shows up in dev tools immediately, not as a confusing server response.
The State API (Drop.state, Drop.shared, Drop.poll, Drop.survey, Drop.access) is always available for the current Droplet; no declaration needed. Reading another Droplet's shared state — Drop.shared.get(key, { droplet }), Drop.shared.list, or includeSharedState on Drop.droplets.list — is the one exception: it needs state:read.
| Token | Grants |
|---|---|
droplets:read | Drop.droplets.list, Drop.droplets.get |
viewer:read | Drop.viewer.me |
types:read | Drop.types.list, Drop.types.get |
fields:read | Drop.fields.get |
fields:write | Drop.fields.set (this Droplet only) |
state:read | Drop.shared.get(key, { droplet }), Drop.shared.list, Drop.droplets.list({ includeSharedState }) — reading another Droplet's shared state |
csp:wasm | Browser WebAssembly — adds 'wasm-unsafe-eval' to the Droplet's CSP (gates no SDK method) |
The capability set is frozen for the life of a deploy. Adding a capability means deploying a new version.
csp:wasm — running WebAssembly
csp:wasm is unlike the tokens above: it gates no SDK method. It relaxes the Droplet's Content-Security-Policy by exactly one source — 'wasm-unsafe-eval' on script-src — so the browser will compile WebAssembly, which in-browser ML runtimes (ONNX Runtime Web, Transformers.js) need. It does not enable eval() or new Function().
It only allows compiling WASM, not downloading it. The library, the .wasm binary, and any model weights are still fetched — declare their hosts with <link rel="preconnect"> so they land in connect-src:
<meta name="drop-sdk-capabilities" content="csp:wasm">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link rel="preconnect" href="https://huggingface.co">
Example: a dashboard of Droplets by tag
A Droplet that lists every other Droplet tagged #dashboard and renders each one's description as a card. About fifteen lines of code; the reference implementation is open to fork.
await Drop.ready;
const { items } = await Drop.droplets.list({
tags: ['dashboard'],
sort: '-updatedAt',
includeFields: true, // fold each Droplet's typed fields inline — no N+1
});
for (const d of items) {
const description = d.typeFields?.description ?? d.description ?? '';
// …render d.name + description into a card
}
includeFields: true is the pattern for dashboards: each item arrives with its typeFields already populated, so you don't need a follow-up Drop.fields.get per row.
Linking a card to its Droplet. Droplets render inside a sandboxed iframe that allows popups but blocks top-frame navigation, so an outbound link to another Drop page must open a new tab — use target="_blank" (with rel="noopener"). target="_top" and target="_self" are silently blocked by the sandbox (you'll see an "Unsafe attempt to initiate navigation… sandboxed" console error and the click does nothing).
Example: rolling up templated Droplets
Teams each deploy a copy of a status template that writes a few Drop.shared values — status, confidence, blockers. A rollup Droplet reads them all in one call with includeSharedState, no per-Droplet round-trip. Reading other Droplets' shared state needs state:read (plus droplets:read to list them):
<meta name="drop-sdk-capabilities" content="droplets:read state:read">
await Drop.ready;
const { items } = await Drop.droplets.list({
type: 'team-status',
includeSharedState: ['status', 'confidence', 'blockers'],
});
for (const d of items) {
const { status, confidence, blockers } = d.sharedState ?? {};
// …render one row per team
}
When you don't know the keys ahead of time, discover them per Droplet with Drop.shared.list({ droplet }), or pass includeSharedState: true to fold in every (non-reserved) key. And to read a single value from one known Droplet, Drop.shared.get(key, { droplet }) is the point-read. All three are read-only and scoped to what the viewer can already see — a Droplet the viewer can't read comes back not_found.
Example: a RAPID decision Droplet
Five viewers vote their RAPID role — Recommend, Agree, Perform, Input, Decide — using Drop.shared for the running tally. When all five roles have voted and at least one is Decide, the Droplet advances its own status field:
await Drop.fields.set('status', 'decided');
Viewers see a toast — "Status set to Decided by this droplet." — naming the Droplet as the source, so the change is distinguishable from a manual edit. A viewer without write access on the Droplet gets forbidden; you don't gate the call yourself. The full implementation is at the RAPID reference Droplet.
Errors
Calls reject with a DropStateError carrying a code and a human-readable message. For most UIs, surfacing err.message directly is enough.
err.code | What it means |
|---|---|
capability_not_declared | You called a method without declaring its capability token. Add the token and redeploy. |
forbidden | The viewer lacks permission for this action. |
not_found | The Droplet or field doesn't exist or the viewer can't see it (these are intentionally indistinguishable). |
validation_failed | A Drop.fields.set value didn't match the field's type. err.fieldErrors lists specifics. |
rate_limited | Too many requests. err.retryAfterMs says when to retry. |
Narrower codes exist for archived Droplets, unknown fields, missing Type configurations, and expired share-link sessions — the full set is in the SDK source.
Limits
- List results: 100 max per page, 25 default. Use the returned
nextCursorfor pagination. - Field values: 256 KB max per JSON value.
- State keys: 1,000 distinct keys per Droplet.
- Cross-Droplet state reads:
Drop.shared.get(key, { droplet })andincludeSharedState: [...]cap at 100 keys per call.includeSharedState: truefolds up to 10,000 total entries across the page — for exhaustive enumeration of a large store useDrop.shared.list(per-Droplet, paged). - Rate: 60 calls/minute per (viewer, Droplet) shared across every surface.
- External viewers (share-link, no SSO): can read polls and shared state; writes are no-ops; meta-SDK methods reject with
auth_required. Gate UI onDrop.viewer.me(). - Realtime:
onChangehandlers update within ~5 seconds via background polling — good for collaboration, not chat. - Outbound links: the Droplet iframe sandbox blocks top-frame navigation. Link to other Drop pages with
target="_blank", not_top/_self.
Where the data lives
A Droplet's detail page has a State tab showing aggregate poll counts (with a voter expand), survey responses (with CSV export), and any Drop.shared values you've written. Per-viewer state from Drop.state is never shown — not even to the owner. Drafts share state with the published Droplet; the owner's Reset all state button is the escape hatch.
Owners can also read and write those shared values from outside a Droplet — drop state get / set / import on the CLI, or the drop_state_get / drop_state_set MCP tools — handy for seeding a Droplet's state from a script or having Claude update it directly.
Operations
Wait for the SDK
Always await Drop.ready before the first SDK call. The SDK bootstraps via a handshake with the parent page; until ready resolves, methods may not be wired up.
await Drop.ready;Drop.viewer.me — who is looking
Returns the current viewer's identity. Use it for personalization, role-gating, or filtering a list of items to "mine." Requires viewer:read.
const me = await Drop.viewer.me();
// { id, name, email, isAdmin, spaceMemberships: [...], ... }Drop.droplets.list / .get — query the catalog
List Droplets the viewer can read. Filters compose: by Type, by tag, by Space, by archived status, sort order. Use the returned nextCursor for pagination. get(slug) fetches a single Droplet by slug; both return not_found for Droplets the viewer can't see. Requires droplets:read. Add includeFields: true to fold each result's typed fields inline, or includeSharedState to fold in shared state (also needs state:read) — both avoid an N+1 round-trip.
const { items, nextCursor } = await Drop.droplets.list({
tags: ['dashboard'],
sort: '-updatedAt',
limit: 25,
});
const detail = await Drop.droplets.get('q3-pricing-decision');
// Aggregate shared state across templated Droplets (needs state:read):
const { items: teams } = await Drop.droplets.list({
type: 'team-status',
includeSharedState: ['status', 'confidence'], // or true for all keys
});
teams[0].sharedState; // { status: 'green', confidence: 0.8 }Drop.fields.get / .set — typed field values
Read or write typed field values. get defaults to the current Droplet; pass a slug to read another (viewer must have access). set writes to the current Droplet only — cross-Droplet writes reject. Viewers see a confirmation toast when an SDK write succeeds, naming the Droplet as the source. Requires fields:read / fields:write.
// Read this Droplet's fields:
const here = await Drop.fields.get();
console.log(here.typeFields.status);
// Read another Droplet's fields (read access required):
const other = await Drop.fields.get('q3-pricing-decision');
// Write a single field — this Droplet only:
await Drop.fields.set('status', 'decided');
// → viewer sees: "Status set to Decided by this droplet"Drop.types — the Type registry
Read Types and their field schemas. Useful when a Droplet needs to render a form for the active Type, or look up the values a select field allows. Requires types:read.
const all = await Drop.types.list();
const decision = await Drop.types.get('decision');
// { name, schema: { primitives, customFields }, ... }Drop.state — per-viewer key-value
Private to the viewer. Use for personal checkboxes, "I read this" markers, personal notes, per-viewer UI prefs. onChange returns an unsubscribe function. No capability required.
await Drop.state.set('action-3', true);
const value = await Drop.state.get('action-3');
// → true for this viewer, null for everyone else
const stop = Drop.state.onChange('action-3', (next) => {
console.log('changed:', next);
});Drop.poll — one vote per viewer
Aggregated counts; voting again replaces the previous vote (no double-counting). The owner sees who voted for what on the State tab; viewers see only the totals. No capability required.
await Drop.poll.vote('offsite-date', 'option-a');
const result = await Drop.poll.results('offsite-date');
// { counts: { 'option-a': 7, 'option-b': 12 }, myVote: 'option-a', totalVotes: 19 }Drop.survey — structured submissions
Each viewer submits a structured response. Re-submitting overwrites the previous answer. The owner sees all responses with email attribution and can export to CSV from the State tab. No capability required.
await Drop.survey.submit('onboarding', {
rating: 4,
notes: 'Day one was great, day two was confusing.',
});Drop.access — who has access
Returns the list of people with read access. Useful for assignment dropdowns, @-mention pickers, or "send to" controls inside the Droplet. No capability required.
const people = await Drop.access.list();
// [{ name, email }, ...]