Getting Started

Build your first Feature

AI generated The content on this page was written using large language models.

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:

src/lib/rough/show-features.ts
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:

src/lib/rough/builder.ts
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.

src/lib/rough/dashboard.ts
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

Load the session and Project ID on the server. This uses the same locals.user and account choice as the token endpoint:

src/routes/dashboard/+page.server.ts
import { env } from '$env/dynamic/private'
import { error, redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = ({ locals }) => {
  if (!locals.user) redirect(303, '/login')
  if (!env.ROUGH_PROJECT_ID) error(500, 'Rough Project ID is missing')
  return {
    roughProjectId: env.ROUGH_PROJECT_ID,
    userId: locals.user.id,
    accountId: locals.user.organisationId,
  }
}

The component imports our dashboard helper inside an effect, which only runs in the browser. That keeps the SDK out of server rendering without disabling SSR for the whole page:

src/routes/dashboard/+page.svelte
<script lang="ts">
  import type { PageProps } from './$types'

  let { data }: PageProps = $props()
  let element: HTMLDivElement
  let button: HTMLButtonElement

  $effect(() => {
    // Read these synchronously so the effect tracks identity changes.
    const options = {
      projectId: data.roughProjectId,
      userId: data.userId,
      accountId: data.accountId,
      element,
      button,
    }
    let cancelled = false
    let stop: (() => void) | undefined

    void import('$lib/rough/dashboard')
      .then(({ attachDashboard }) => {
        if (!cancelled) stop = attachDashboard(options)
      })
      .catch((error) => console.error('Could not load Rough', error))

    return () => {
      cancelled = true
      stop?.()
    }
  })
</script>

<div bind:this={element}></div>
<button bind:this={button} type="button" disabled>Create a Feature</button>

When the page data changes to a different user or account, the effect stops the old integration and attaches the new one. Make sure your existing account-switch flow invalidates the page data; changing a cookie alone doesn’t rerun a Svelte effect. Don’t add a top-level SDK import to this component or a server load file.

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.