Getting Started
Build your first Feature
Vanilla
We have authentication and a Surface describing what Features can read. Now we’ll put Rough on the page: a button to open the Builder, and a place to display published Features.
Your app owns the surrounding UI. Put these wherever they make sense for your users; the dashboard paths below are examples, not required route names.
Display published Features
subscribeToRoughFeatures gives you the published Features on your Surface. mountRoughFeature displays each one in an element you provide. This helper adds and removes mounts as the list changes, leaving existing Features running:
import { mountRoughFeature, subscribeToRoughFeatures } from '@roughapp/feature'
import type {
RoughClient,
RoughFeatureMount,
RoughSurface,
} from '@roughapp/feature'
export function showRoughFeatures(options: {
client: RoughClient
surface: RoughSurface
element: HTMLElement
}) {
const { client, surface, element } = options
const mounted = new Map<
string,
{ container: HTMLElement; mount: RoughFeatureMount }
>()
function unmount(id: string) {
const entry = mounted.get(id)
if (!entry) return
mounted.delete(id)
entry.mount.unmount()
entry.container.remove()
}
const unsubscribe = subscribeToRoughFeatures({
client,
surface,
onChange(features) {
const published = new Set<string>(features.map((feature) => feature.id))
// Remove Features that are no longer published.
for (const id of mounted.keys()) {
if (!published.has(id)) unmount(id)
}
// Add new ones. Leave the others alone so they keep running.
for (const feature of features) {
if (mounted.has(feature.id)) continue
const container = document.createElement('div')
element.append(container)
const mount = mountRoughFeature(container, {
client,
surface,
featureId: feature.id,
})
mounted.set(feature.id, { container, mount })
}
},
})
// Call this when the page goes away.
return function stop() {
unsubscribe()
for (const id of [...mounted.keys()]) unmount(id)
}
}
The first list may be empty while Features load, and unpublished drafts aren’t included. Mounted Features follow new publications automatically; you don’t need to recreate them each time someone publishes an update.
Open the Builder
We’ll create a draft, then open it in the Feature Builder:
import { createRoughFeature, openRoughBuilder } from '@roughapp/feature'
import type { RoughClient, RoughSurface } from '@roughapp/feature'
export function featureCreator(client: RoughClient, surface: RoughSurface) {
return async function createFeature() {
const feature = await createRoughFeature({ client, surface })
await openRoughBuilder({ client, surface, featureId: feature.id })
}
}
Each call creates a new draft. Closing the Builder doesn’t delete that draft or cancel a build. To reopen an existing Feature, call openRoughBuilder with its ID instead of creating another one. Published Features also have an edit control.
Give the dashboard a lifecycle
This browser-only helper brings together the files we’ve written so far. It creates a fresh data adapter and Surface for the current account, then starts the client. When the view goes away, it unmounts Features before destroying the client.
The small queue waits for asynchronous teardown before starting another client. That matters when the user switches accounts or your framework remounts the view quickly.
import { featureCreator } from './builder'
import { startClient } from './client'
import { createDashboardData } from './data-source'
import { showRoughFeatures } from './show-features'
import { createDashboardSurface } from './surface'
type Options = {
projectId: string
userId: string
accountId: string
element: HTMLElement
button: HTMLButtonElement
}
// This helper owns one dashboard view. Keep it in browser-only code.
let transition = Promise.resolve()
function schedule(task: () => void | Promise<void>) {
transition = transition.then(task).catch((error) => {
console.error('Could not update the Rough dashboard', error)
})
}
export function attachDashboard(options: Options) {
const { element, button, accountId } = options
let cancelled = false
let client: ReturnType<typeof startClient> | undefined
let stopFeatures: (() => void) | undefined
let onCreate: (() => void) | undefined
button.disabled = true
schedule(() => {
if (cancelled) return
const data = createDashboardData(accountId)
const surface = createDashboardSurface(data)
client = startClient(options)
stopFeatures = showRoughFeatures({ client, surface, element })
const createFeature = featureCreator(client, surface)
onCreate = () => {
button.disabled = true
void createFeature()
.catch((error) => console.error('Could not create a Feature', error))
.finally(() => {
if (!cancelled) button.disabled = false
})
}
button.addEventListener('click', onCreate)
button.disabled = false
})
return () => {
cancelled = true
button.disabled = true
if (onCreate) button.removeEventListener('click', onCreate)
schedule(async () => {
stopFeatures?.()
await client?.destroy()
})
}
}
This example owns one dashboard view. If you show Rough on several pages at once, move client ownership into a shared layout or provider instead of starting another client for each view. Keep one client per project, user, and account.
The example reports failures to the console to keep the integration code focused. Before shipping, show your app’s normal error UI for authentication, loading, and create failures too.
Add it to your page
Add a container and button to your existing page:
<div id="rough-features"></div>
<button type="button" id="create-feature" disabled>Create a Feature</button>
The function below is a lifecycle adapter for your app. Call it after those elements exist, using your actual session and Project ID. Save its returned cleanup function and call it when the view is removed, before logout, or before mounting for a different user or account.
import { attachDashboard } from './rough/dashboard'
export function mountDashboard(identity: {
projectId: string
userId: string
accountId: string
}) {
const element = document.querySelector<HTMLElement>('#rough-features')
const button = document.querySelector<HTMLButtonElement>('#create-feature')
if (!element || !button) throw new Error('Dashboard elements are missing')
return attachDashboard({ ...identity, element, button })
}
For example, in your existing authenticated view lifecycle:
// appConfig and signedInUser come from your app, not from Rough.
const stopRough = mountDashboard({
projectId: appConfig.roughProjectId,
userId: signedInUser.id,
accountId: signedInUser.organisationId,
})
// Register stopRough with your router/view's teardown hook.
// On an account switch, call it before mounting the next identity.
The Project ID is safe to include in browser configuration. The signing key is not. If your page is server-rendered, load page.ts only in the browser; don’t import it into server code.
Defining a cleanup function doesn’t run it. Make sure your router or view actually calls stopRough when ownership ends.
Build something using your data
Sign in to your app and click Create a Feature. The Builder should list the capability you defined in step 3, with your own name and description.
Ask for something useful using that data. For our books example, we might say:
Show my books grouped by reading status. Include each book’s title and author.
For support tickets, you might ask for unresolved tickets grouped by priority instead. Use the capability names and fields you actually exposed; there’s no special prompt to copy.
Wait for the build, which can take a few minutes. The preview uses your fictional sample. Publish the Feature and close the Builder: it should now appear in your app, using the real data from your implementation.
If the preview works but the published Feature doesn’t, check your data-access request and its schema. If the Builder doesn’t open, check the token request and browser console. An HTMLElement is not defined error usually means the SDK was imported on the server.
Before moving on, confirm the published Feature shows your app’s data—not just a successful preview. A Query reads when called; next we’ll give the Feature a way to follow changes.
A note about Feature state
The data you expose through capabilities stays under your app’s access rules. A Feature can also save its own UI state. By default, that state is stored in browser local storage by Feature ID, not by user or account. If you need user/account isolation for that state, supply a scoped datastore when mounting; see the SDK reference. Don’t treat the default local storage as private per-user storage.