Skip to main content

Building Apps on Starhive — Developer Guide

Who this is for: developers building an app that runs inside Starhive. Scope: the whole journey — set up, scaffold, model your data, build the UI, iterate, deploy, test, roll out to customers, and maintain. You should already know: TypeScript and React. You do not need to know: anything about how Starhive is built or hosted. You never run a Starhive service, and this guide never asks you to.


1. What a Starhive app is​

1.1 The model in one minute​

A Starhive app is a static web bundle — a Vite + React project you build to a folder of files. You upload it with the CLI; Starhive hosts it and renders it inside a sandboxed iframe at the places in the product you declare. Inside that iframe, the @starhive/bridge SDK gives you the current context (workspace, user, theme) and a small CRUD API over your app's data.

your project Starhive
┌────────────────┐ ┌────────────────────────┐
│ manifest.yaml │──deploy─────▶│ hosts your bundle │
│ React app │ │ provisions your data │
└────────────────┘ │ renders your UI in │
│ the places you asked │
└───────────┬────────────┘
│
┌─────────▼──────────┐
│ your app, running │
│ as the person │
│ looking at it │
└────────────────────┘

Three things to internalise before you write a line of code:

  1. Your data is native Starhive data. Your app's "database" is regular Starhive Spaces, Types, Attributes and Objects. You declare them in manifest.yaml; Starhive creates them in the customer's workspace when they install. You don't run a database, and search, reporting, permissions, views and audit all work on your app's data for free — your customers can build their own dashboards over it without asking you.
  2. Your app acts as the user. Your iframe never holds a credential. Every data call goes to Starhive, which performs it as the logged-in user and returns the result. Your app can do exactly what that user can do — never more — and never sees a token. You don't implement auth or permissions; you inherit them.
  3. You upload, you don't host. Starhive serves your bundle from an isolated origin it controls. The manifest never points at a server of yours, because you don't need one.

1.2 What you get for free​

You declareStarhive gives you
appA stable identity, versioning, and an immutable version history
modulesPlacement at four extension points, sandboxing, theming
configA typed admin-settings store per install — no database of your own
data.space / data.typesReal Spaces, Types and Attributes — searchable, reportable, permissioned
data.workflowsStatus state machines on your types
data.seedStarter objects, so the app isn't empty on day one
(nothing)Hosting, TLS, sandboxing, auth, install/upgrade/uninstall flow

1.3 What you cannot do​

Read this now — it saves a redesign later.

  • No backend of your own. There is no place to run server-side code. Your app is a frontend plus native Starhive objects. If your idea needs a cron job, a webhook receiver, or code that runs when nobody is looking, it does not fit this model today.
  • No undeclared network calls — and, as far as this pass could confirm, no declared ones either. The sandbox blocks your app from reaching any host but its own origin (connect-src 'self' — see §13). There is no manifest field that opens an exception to this today: scopes and remotes, which the original version of this guide described as the way out (§5.7, §5.8), don't exist in the manifest schema. A Starhive app cannot reach an external system and cannot read data outside its own provisioned types, as far as this pass could confirm.
  • No secrets in your app. The bundle is public static content. Treat everything in it as readable by anyone.
  • A REFERENCE attribute targets exactly one type, named by targetType. (AttributeDef has no includeChildren field — see §5.6.)
  • No install-time hooks or event subscriptions. Your app runs when someone opens it, and only then.

2. Prerequisites​

RequirementNotes
Node.js ≥ 18To build your app. Node 20+ recommended.
A Starhive account and a workspaceOne you can install apps into and use as your test workspace.
A Personal Access Token (PAT)Generated in the Starhive web app — see Personal Access Token. This is how the CLI authenticates as you.
The Starhive CLIstarhive — see Starhive CLI.

That's the whole list. You don't need Docker, a database, or any part of Starhive running locally.


3. Set up your machine​

3.1 Install the CLI​

Standalone binary (no Node required) — macOS / Linux, x64 or arm64:

curl -fsSL https://cli.starhive.com/install.sh -o starhive-install.sh
bash starhive-install.sh

Installs to ~/.local/bin/starhive. Override with STARHIVE_INSTALL_DIR=/usr/local/bin, or pin a version with STARHIVE_VERSION=v0.1.0.

Windows (x64):

curl.exe -fsSL https://cli.starhive.com/install.ps1 -o starhive-install.ps1
powershell -ExecutionPolicy Bypass -File .\starhive-install.ps1

Installs starhive.exe to %LOCALAPPDATA%\Programs\starhive and adds it to your user PATH. Open a new terminal afterwards. Windows ARM64 is not supported by the binary — use npm instead.

npm (anywhere with Node ≥ 18):

npm install -g @starhive/cli
starhive --version

3.2 Authenticate​

Generate a Personal Access Token in the Starhive web app, then:

starhive access

It prompts with hidden input and saves the token to .starhive/config.json (permissions 0600) in the current directory. Inside a git repo, .starhive/ is added to the nearest .gitignore for you.

caution

That file holds a live credential. Never commit it, and never paste it into a ticket, a chat, or this page.

You can re-run starhive access any time to swap the token.

3.3 Configuration​

The CLI looks for .starhive/config.json in the current directory or any ancestor (like .git), so config is per-project. For each setting: environment variable → config file → built-in default.

The two settings that matter for app development:

Config keyEnv varDefaultPurpose
tokenSTARHIVE_TOKEN(unset)Your PAT, sent as Authorization: Bearer ….
appPlatformUrlSTARHIVE_APP_PLATFORM_URLhttps://api.starhive.comThe Starhive API your app deploys to. You should never need to change this.

Set DEBUG=starhive for verbose CLI logging when something goes wrong.


4. Create your app​

starhive app create "Time Reporting"
starhive app create "Time Reporting" --key com.acme.timereporting --dir ./apps/time-reporting
Arg / flagDefaultDescription
<name>—Display name; also the default folder and package name (slugified).
--key <appKey>com.example.<slug>The manifest app key — globally unique, reverse-DNS, permanent.
--dir <path>./<slug>Where to create the project. Must be empty or absent.

Choose a real --key now. It is your app's permanent identity — com.<yourcompany>.<app>. Changing it later doesn't rename your app, it creates a different one, and existing installs stay with the old key.

cd time-reporting
npm install
npm run build

What you get​

time-reporting/
├── manifest.yaml # identity, modules, config, data model
├── index.html
├── vite.config.ts # base: './' + aliases for the vendored SDK packages
├── tsconfig.json
├── package.json # dev / build / preview / tscheck / deploy scripts
└── src/
├── main.tsx # createRoot + Suspense (the bridge suspends until it connects)
├── App.tsx # reads context.slot and renders the matching screen
├── slots/
│ └── GlobalPage.tsx # one component per extension point
└── bridge/ # vendored @starhive/bridge — see below

Two things about the scaffold that will bite you if you change them blindly:

  • base: './' in vite.config.ts is load-bearing. Your bundle is served from a versioned sub-path, so assets must be referenced relatively. Absolute /assets/… URLs will 404 inside the iframe.
  • The SDK is vendored, not installed. @starhive/bridge, @starhive/ui and @starhive/theme aren't published to npm yet, so their source is copied into src/ and aliased in vite.config.ts and tsconfig.json. Import them by package name as normal. When they ship on npm you'll delete the vendored folders, drop the aliases, and add real dependencies — with no changes to your own code.

5. The manifest — identity, UI, settings, data​

manifest.yaml declares everything Starhive needs to register your app, render it, and set up its data. It's the most important file in the project.

5.1 Full skeleton​

app:
key: com.vendor.app # globally unique, reverse-DNS — your app's identity (permanent)
version: 1.0.0 # semver — bump to ship a new version
description: One line describing what the app does.
icon: extension # optional icon name

modules: # where your bundle mounts (each points at a route inside it)
globalPage:
- { key: main, title: My App, icon: extension, route: / }
objectPanel:
- { key: panel, title: Details, route: /, showFor: "${config.linkedType}" }
widget:
- { key: summary, title: Summary, route: / }
settingsPage:
- { key: settings, route: / }

config: # admin settings per install (NOT stored as objects)
- { key: linkedType, name: Linked type, type: typeRef, default: item }
- { key: defaultDueDays, name: Default due in (days), type: number, default: 7 }

data: # created in the customer's workspace on install
space: { key: app-data, name: My App }

workflows:
- key: lifecycle
name: Lifecycle
states:
- { key: open, name: Open, initial: true }
- { key: inProgress, name: In progress }
- { key: done, name: Done, end: true }
transitions:
- { name: Start, from: open, to: inProgress }
- { name: Complete, from: inProgress, to: done }
- { name: Reopen, from: done, to: open }

types:
- key: item
name: Item
labelAttribute: title
attributes:
- { key: title, name: Title, type: TEXT, required: true }
- { key: owner, name: Owner, type: USER }
- { key: dueDate, name: Due date, type: DATE }
- { key: amount, name: Amount, type: DECIMAL }
- { key: priority, name: Priority, type: OPTION, config: { options: [Low, Medium, High] } }
- { key: status, name: Status, type: WORKFLOW, workflow: lifecycle }
- { key: relatedItem, name: Related, type: REFERENCE, targetType: "${config.linkedType}", cardinality: one }

seed:
item:
- { title: First item, priority: High }
- { title: Second item }

5.2 app — identity​

FieldRequiredNotes
keyyesGlobally unique, dot notation. Permanent — this is your app.
versionyesSemver. Every new value you deploy becomes an immutable version.
descriptionnoOne line, shown in listings.
iconnoIcon name.
note

name and vendor are not manifest fields. You set them when you register the app in the web console. The key is the identity; the display name is metadata you can change without redeploying.

5.3 modules — the four extension points​

SlotWhere it rendersWhat you additionally get
globalPageA full-page destination — "where the app starts"workspace, user
objectPanelA panel/tab on a specific objectobjectId — the object being viewed
widgetA widget on sites/dashboards (transparent background)workspace
settingsPageYour app's admin settings formthe only slot that may write config

Every module takes key and route; title and icon name it wherever the host lists it. showFor is specific to an objectPanel:

objectPanel:
- { key: panel, title: Details, route: /, showFor: "${config.linkedType}" }

showFor is a config binding, resolved per install: the tab is offered only on objects of the type(s) the admin nominated in that setting, subtypes included. Leave it out and the panel is offered on every object — which is rarely what you want once your app is about something specific.

Each module names a route inside your bundle. One bundle typically serves every slot with route: /, and the app branches on context.slot:

function Screen() {
const { slot } = useStarhiveContext()
switch (slot) {
case 'objectPanel': return <ObjectPanel />
case 'widget': return <Widget />
case 'settingsPage': return <SettingsPage />
case 'globalPage':
default: return <GlobalPage />
}
}

5.4 Macros — not a real slot​

There is no fifth extension point for embedding your app directly inside a rich-text document (as opposed to a panel, widget, or page). modules supports exactly four slots — globalPage, objectPanel, widget, settingsPage (§5.3) — and nothing else. If you need a surface embedded inline in a document rather than a panel/widget/page, it isn't available in the app platform today.

5.5 config — admin settings and deferred bindings​

config declares typed fields an admin fills in for their install. Starhive stores them for you — this is not object data — and you read them with config.get(). Only the settingsPage slot may write them.

A config field has key, name, help, type (typeRef | reference | number | text | boolean), multi, required, targetType, default.

The real power is letting a customer point your app at their own data. An attribute's targetType can be a ${config.someKey} expression, resolved at install time to whatever type the admin picked:

config:
- { key: linkedType, name: Linked type, type: typeRef, default: item }
data:
types:
- key: task
attributes:
- { key: relatedItem, type: REFERENCE, targetType: "${config.linkedType}", cardinality: one }

A typeRef default naming one of your own type keys (item) resolves to that type automatically, so your app works out of the box and an admin can re-point it later.

5.6 data — your data model​

Everything under data becomes native Starhive structures in the customer's workspace. You declare stable logical keys; Starhive maps each to a real id per workspace. That mapping is what lets one manifest install cleanly into every customer.

The space holds your app's objects.

space: { key: app-data, name: My App }

The space block has exactly two fields, key and name — there's no access field. Who can read the space is whatever Starhive's default permissions are for a newly created space; an admin can change that afterward in the space's own permissions (the same mechanism Shipping a new version points to for widening access later).

Attribute types:

typeExtra fieldsNotes
TEXT—Plain text. Usually the labelAttribute.
DATE—YYYY-MM-DD.
DECIMAL—Number.
BOOLEAN—True/false.
USER—A Starhive user reference.
OPTIONconfig: { options: [A, B], multi: true }Enumerated choices. Options go inside config:.
REFERENCEtargetType: (a type key or ${config.*}), cardinality: oneLink to another type. Omit cardinality for many. Always matches the target type exactly — there's no includeChildren toggle to also include its subtypes.
WORKFLOWworkflow: <workflowKey>Status driven by a workflow you declared. Moved by applying a transition — see below.

Common fields on every attribute: key, name, type, required, default.

labelAttribute names the attribute used as an object's human label. It must be a TEXT attribute, and every object needs a non-empty label — so give it a sensible fallback wherever you create objects.

Workflows need exactly one initial: true state; mark terminal states end: true. A WORKFLOW attribute binds to one by its key.

Moving a status is a transition, not a value. Starhive never lets you simply write a new state onto a WORKFLOW attribute — an update that names a state without naming the move that got there is refused. That's what makes the state machine mean anything: the graph is enforced, and so are any conditions on the edge you're crossing. §6.5 shows the two-step pattern (ask what moves are available, then write the state and the transition together).

context.stateKeyToId maps "workflowKey.stateKey" to the real state id, so you can also label a status yourself without asking anything.

Seed rows are typeKey → list of rows, keyed by attribute key. They're created only for types created in that pass, so reinstalls and upgrades never duplicate them.

5.7 scopes — not implemented​

There is no manifest field for declaring read access to a customer's other data. A Starhive app can read and write only the types it provisions itself (data.types) — nothing else. A ${config.*}-bound REFERENCE attribute (§5.5) lets an admin point one of your attributes at one of their types, but that's your app's own type gaining a reference to theirs, not your app reading their type's other objects directly.

5.8 remotes — not implemented​

There is no manifest field for declaring an external system, having an admin connect it with a stored credential, and calling it from your app. Your iframe can reach its own origin and nothing else, with no declared way to widen that today (see §13 for the CSP that enforces this): there's a per-app-version widening mechanism at the infrastructure layer, but nothing populates it, so it can't be reached from a manifest today. A Starhive app cannot reach an external system as of today — don't design around an escape hatch for this existing.

5.9 Rules and gotchas​

  • There is no deprecated: true retirement mechanism, and deploy does not refuse breaking changes. Nothing in data is protected from removal: deploy only refuses re-deploying the exact version number that's already published (immutable) — it does not diff schemas, and it does not refuse removing a type or attribute that a live workspace depends on. See Shipping a new version §8-11 for the full account of the release model this builds on.
  • Address attributes by key, not by name. context.attributeKeyToId["item.priority"] and bridge.resolveAttributeKey('item.priority') both resolve the key you declared; matching on the display name breaks the day an admin renames it in their own workspace.
  • Re-deploying a version that is not published replaces it — manifest and bundle both. Re-deploying the published version is refused; bump app.version to ship changes. See Shipping a new version.
  • The manifest is validated on deploy, but the real checks are narrower than the original guide described: app.key must be dot-notation and non-blank, app.version non-blank, at least one module declared, every ${config.*}-bound attribute must reference a config field that actually exists, every seed key must reference a declared type, and every workflow must have at least one state, no duplicate state keys, exactly one initial state, and every transition's from/to and every WORKFLOW attribute's workflow must reference states/workflows that exist. There is no macro-specific validation (macros don't exist — see §5.4) and no breaking-change/diff validation (see the bullet above).
  • Pricing is not in the manifest — and today it is nowhere else either; every app is free (§11.7).

6. Build the UI with @starhive/bridge​

6.1 Mounting​

The bridge is a lazily-created singleton, so the hooks work with no provider to wire up. What you do need is a Suspense boundary: useStarhiveContext() suspends until Starhive hands your iframe its context, which is what lets you destructure it unconditionally instead of null-checking everywhere. The scaffold sets this up:

createRoot(root).render(
<StrictMode>
<Suspense fallback={<div>Connecting…</div>}>
<App />
</Suspense>
</StrictMode>,
)

StarhiveProvider exists but is optional — use it only to inject a specific bridge instance, such as a mock in tests:

<StarhiveProvider bridge={mockBridge}>
<App />
</StarhiveProvider>

6.2 The context​

const {
installationId,
slot, // 'globalPage' | 'objectPanel' | 'widget' | 'settingsPage'
workspaceId,
spaceId, // your app's space in this workspace
objectId, // objectPanel only
user, // { id, name?, email }
theme, // design tokens, pushed live when the workspace theme changes
typeKeyToId, // "item" -> the type id in this workspace
attributeKeyToId, // "item.priority" -> the attribute id in this workspace
stateKeyToId, // "lifecycle.done" -> the workflow state id
} = useStarhiveContext()

Every key your manifest has ever declared stays in these maps once provisioned — there's no deprecation mechanism (see §5.9) and no retirement path for a key, so nothing is ever withheld.

6.3 The API surface​

Hooks: useStarhiveContext, useTheme, useObjects, useObject, useObjectQuery, useObjectAggregate, useTransitions, useConfig, useType, useTypes, useTypeById, useAttribute, useAttributes, useToast, useNavigate, useBridge.

Imperative (const bridge = useBridge()):

bridge.objects.create(typeKey, attributes, options?) // attributes: { attributeId, values: string[] }[]
bridge.objects.get(id)
bridge.objects.update(id, attributes, options?)
bridge.objects.remove(id) // → { id }
bridge.objects.query(starql, { typeKey, offset?, limit? }) // typeKey required
bridge.objects.aggregate({ typeKey, operation, attribute?, where?, groupBy? }) // count/sum/avg/min/max

bridge.getType(typeKey) // full schema
bridge.listTypes({ spaceId? }) // light type refs, for pickers

bridge.config.get()
bridge.config.set({ … }) // settingsPage slot only

bridge.workflow.transitions(objectId, attributeId) // the moves available right now — see §6.5

bridge.resolveTypeKey(typeKey) // throws with code BAD_REQUEST if the key isn't provisioned
bridge.resolveAttributeKey(attributeKey)

bridge.toast(message, variant?) // 'success' | 'error' | 'info' | 'warning'
bridge.navigate(to)

6.4 Logical keys, not raw ids​

Your code refers to the logical keys from your manifest, never UUIDs — that's what makes it portable across workspaces. Both halves resolve from the context you were handed, with no fetch:

const { typeKeyToId, attributeKeyToId } = useStarhiveContext()

typeKeyToId['timeEntry'] // the type id here
attributeKeyToId['timeEntry.hours'] // the attribute id here

// Or, imperatively, with a clear error when a key isn't provisioned:
bridge.resolveTypeKey('timeEntry')
bridge.resolveAttributeKey('timeEntry.hours')
note

Don't match on display names. An earlier version of this guide resolved attributes by looking for a.name === 'Hours' on the type schema. That breaks the day an admin renames the attribute in their own workspace — which they are entitled to do, because it is their data. Keys are yours and never change; names are theirs and do.

export function readValue(object: BridgeObject, attributeId: string) {
return object.attributes.find((a) => a.attributeId === attributeId)?.values[0]
}

All values are string-encoded: REFERENCE = the target object's UUID, DATE = YYYY-MM-DD, DECIMAL and TEXT = raw.

6.5 Worked examples​

Create an object:

const objects = useObjects()
const { attributeKeyToId } = useStarhiveContext()
const toast = useToast()

await objects.create('timeEntry', [
{ attributeId: attributeKeyToId['timeEntry.date'], values: ['2026-08-30'] },
{ attributeId: attributeKeyToId['timeEntry.hours'], values: ['1.5'] },
{ attributeId: attributeKeyToId['timeEntry.notes'], values: ['Standup'] },
])
toast('Logged 1.5h', 'success')

Query with StarQL:

const { data, isLoading, error, refetch } = useObjectQuery(
'"Work Item" = objectId("…") order by Created desc',
{ typeKey: 'timeEntry', limit: 100 },
)
// data: { result: BridgeObject[], total, pageSize, isLast }

typeKey is required — queries are scoped to your app's own types.

Read and write admin settings (settingsPage only):

const { config, isLoading, setConfig } = useConfig()
await setConfig({ roundingMinutes: 15 }) // fails with FORBIDDEN from any other slot

Moving an object's status from inside your app:

const { data, refetch } = useTransitions(objectId, statusAttributeId)
const objects = useObjects()

return data?.transitions.map((t) => (
<button
key={t.id}
onClick={async () => {
await objects.update(objectId, [{ attributeId: statusAttributeId, values: [t.toStateId] }], {
transitions: { [statusAttributeId]: t.id },
})
refetch()
}}
>
{t.name}
</button>
))

useTransitions returns the moves available right now on one WORKFLOW attribute — what to draw the buttons from. Applying one is a single objects.update call: write the new state and name the transition that got you there in the same call, via the transitions option keyed by attribute id. Starhive refuses a state written without its transition, which is what makes the workflow graph mean anything. Call refetch() afterward, since a successful move changes which transitions are next available.

You can find the status attribute without hard-coding its name — on a type's schema, a WORKFLOW attribute carries a workflowId.

6.6 Two stores — don't mix them up​

  • objects.* = user data. Native objects in your app's space. Search, reporting, permissions and audit all apply, and customers can build their own views over them.
  • config.* = admin settings for one install. Not objects, not queryable, not visible to end users. Writable only from settingsPage.

6.7 Theming: light, dark, and the workspace's own colors​

Your app renders inside Starhive, so it has to look like it belongs there — in whichever scheme the person viewing it uses. You don't guess at any of this: the host hands you the exact values.

What you're given​

context.theme is the whole design system, already resolved for the current scheme:

TokenMeaning
colorScheme'light' or 'dark' — the scheme everything below is already resolved for
fontFamilyThe product font stack
colors.primary / primaryHover / onPrimaryBrand/action color, its hover shade, and the foreground to put on it
colors.backgroundPage background
colors.surfaceCard, input and panel background
colors.text / textMutedPrimary and secondary text
colors.borderHairline borders
colors.positive / warning / negativeStatus colors
radiussm md lg
spacingxs sm md lg xl
fontSizesxs sm md lg xl
fontWeightsnormal semiBold bold
shadowssm md

Two properties of this matter more than the list itself:

It is already resolved. You never write colorScheme === 'dark' ? '#fff' : '#000'. Dark mode is not a branch in your code — it's the same code reading different values. If you find yourself branching on colorScheme, you're rebuilding something you were handed.

It is pushed, not fetched. When a user switches to dark mode, the host sends a new theme over the bridge and useTheme() re-renders. There is no reload and no event to subscribe to. If your app re-themes only after a refresh, something is caching that shouldn't be.

If you use @starhive/ui (what the scaffold does)​

Two halves. Most apps do the first and forget the second.

Half one — the scheme. Hand colorScheme to StarhiveAppProvider and you inherit Starhive's light and dark palettes, the product font, and component styling:

export function App() {
const { colorScheme } = useTheme()
useApplyHostTheme() // ← half two
return (
<StarhiveAppProvider colorScheme={colorScheme}>
<Screen />
</StarhiveAppProvider>
)
}

Half two — the colors. The host doesn't only say which scheme; it sends the values, and for some slots they are deliberately not the workspace defaults (see the widget case below). Apply them on top. The scaffold ships this as src/useApplyHostTheme.ts:

import { useTheme } from '@starhive/bridge'
import { useEffect } from 'react'

export function useApplyHostTheme(): void {
const theme = useTheme()

useEffect(() => {
const root = document.documentElement
const overrides: Record<string, string | undefined> = {
'--mantine-color-body': theme.colors.background,
'--mantine-color-text': theme.colors.text,
'--mantine-color-dimmed': theme.colors.textMuted,
'--mantine-color-default-border': theme.colors.border,
}
for (const [name, value] of Object.entries(overrides)) {
if (value) root.style.setProperty(name, value)
}
return () => {
for (const name of Object.keys(overrides)) root.style.removeProperty(name)
}
}, [theme])
}

Inline custom properties on :root outrank the stylesheet rules the component library emits, so a value the host sends wins, and anything it omits keeps the theme's own light/dark value.

If you don't use @starhive/ui​

Plain CSS, Tailwind, styled-components — same idea, one step instead of two. Publish the whole theme as CSS variables and write your styles against those, never against literal colors:

const kebab = (name: string) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)

export function useApplyHostTheme(): void {
const theme = useTheme()

useEffect(() => {
const root = document.documentElement
root.style.setProperty('--app-font', theme.fontFamily)
for (const [name, value] of Object.entries(theme.colors)) {
root.style.setProperty(`--app-color-${kebab(name)}`, value) // --app-color-text-muted, …
}
for (const [name, value] of Object.entries(theme.radius)) {
root.style.setProperty(`--app-radius-${name}`, value)
}
for (const [name, value] of Object.entries(theme.spacing)) {
root.style.setProperty(`--app-space-${name}`, value)
}
// Useful for `color-scheme` and for any CSS you still want to branch on.
root.dataset.colorScheme = theme.colorScheme
}, [theme])
}
.card {
background: var(--app-color-surface);
color: var(--app-color-text);
border: 1px solid var(--app-color-border);
border-radius: var(--app-radius-md);
padding: var(--app-space-md);
}

That stylesheet is scheme-agnostic. Switching to dark changes the variables, not the rules.

Also set color-scheme so the browser themes what you don't control — scrollbars, native form controls, autofill:

:root[data-color-scheme='dark'] { color-scheme: dark; }
:root[data-color-scheme='light'] { color-scheme: light; }

The widget case (why half two exists)​

A widget doesn't get the workspace background. The dashboard cell behind your iframe has already painted itself — its own background, border and radius, configured by whoever built that dashboard — and your app is meant to sit inside it invisibly. So the host hands a widget:

  • colors.background: 'transparent' and colors.surface: 'transparent'
  • colors.text set to that cell's configured foreground color

An app that reads only colorScheme ignores all of it and paints an opaque rectangle on someone's dashboard, in both schemes. Applying theme.colors is what makes a widget blend — and it's why the fix is "apply the colors you're given," not "add a dark stylesheet."

The rules​

  • Never hardcode a color. Not hex, not white, not rgba(0,0,0,.05). Every literal color is a bug in one of the two schemes. Use theme tokens, or your component library's token names (c="dimmed", c="negative.6"), which already follow the scheme.
  • Don't paint a background you weren't given. Especially in a widget.
  • Keep the boot moment transparent. Until your styles load, the document paints default white — a white flash inside a dark workspace. The scaffold's index.html sets body { margin: 0; background: transparent; }; your styles load later and win.
  • Images and icons need the same care. A dark-on-transparent PNG disappears in dark mode. Prefer inline SVG with fill="currentColor".
  • Don't cache theme values in state. Read them from useTheme() on every render, or your app keeps yesterday's colors after a switch.

Checking your work​

  1. Open your app and switch the workspace between light and dark. It should re-theme without a reload. If it doesn't, you're caching or branching somewhere.
  2. Put your widget on a dashboard and look at its edges. If you can see a rectangle, you're painting a background you shouldn't be.
  3. Give the dashboard cell a non-default appearance colour. Your widget's text should follow it.
  4. Reload in dark mode and watch the first paint for a white flash.
  5. Check disabled inputs, placeholders, focus rings and scrollbars — the details that fall back to browser defaults when color-scheme isn't set.

6.8 Errors​

Every bridge call can reject with a StarhiveBridgeError carrying a stable code:

CodeUsual cause
TIMEOUTNo response within 15 s.
BAD_REQUESTMalformed args — e.g. a query with no typeKey.
FORBIDDENconfig.set outside settingsPage, or touching data outside your app's scope.
NOT_FOUNDThe object or type doesn't exist, or isn't visible to this user.
UNKNOWN_METHODYour vendored SDK is newer than the Starhive version you're running against.
INTERNALStarhive-side failure.
CHANNEL_CLOSEDThe iframe was torn down mid-request.

NOT_FOUND and FORBIDDEN are normal, not exceptional: your app runs as the user, and some users can't see some things. Surface failures with toast(message, 'error') rather than throwing into a blank frame.


7. Develop and iterate​

Your app only comes alive inside Starhive — that's where the bridge has something to talk to. So the loop is build → deploy → reload the page in Starhive:

npm run build && starhive app deploy

or, in one step:

npm run deploy # the scaffold's script: build + deploy

Then reload the Starhive page where your module renders. There is no hot reload through the host.

Working on components outside Starhive. npm run dev serves your UI standalone on http://localhost:4500, which is fine for pushing pixels around, but the bridge has no host to connect to there — useStarhiveContext() will suspend forever and you'll sit on the "Connecting…" fallback. If you want to iterate on layout outside Starhive, render your components with mock data behind a dev-only flag, or inject a mock bridge with StarhiveProvider.

A practical rhythm:

  1. Get the manifest right first and deploy once — provisioning shapes everything else.
  2. Install the app in a test workspace and keep that tab open.
  3. Build + deploy, reload, repeat.
  4. Use toast() liberally while developing; a failed bridge call is otherwise invisible.
  5. Check the iframe's own console (not the page's) for your app's errors.

Re-deploying the same app.version replaces that version in place, so you can iterate on one number all afternoon. The one exception is the version you have already promoted: that one is immutable, and a re-deploy of it is refused. Bump when you have something worth keeping.


8. Deploy​

starhive app deploy [--build] [--dir ./build] [--manifest ./manifest.yaml]
FlagDefaultPurpose
--dir <path>./buildThe built bundle directory to upload.
--manifest <path>./manifest.yamlYour manifest.
--build(off)Run npm run build first.
--api <url>https://api.starhive.comDeploy somewhere other than the default. You'll only ever need this if Starhive tells you to.

Deploy zips your bundle (files at the archive root, so index.html is at the top), registers the manifest as a new version, uploads the bundle, and finalises it:

→ zipping ./build…
→ publishing manifest…
→ uploading bundle (42 KB)…
→ finalizing…

✓ Deployed 1.2.3
appId: …
versionId: …
bundleStatus: READY
serveUrl: https://…/index.html

Two things to understand about what a deploy does — and doesn't — do:

  • It doesn't change what customers run. A deploy moves only the latest deployed build, which is what you, the developer, receive. Customers stay on the published version until you promote. See §10.
  • It doesn't make your app installable by anyone else. A brand-new app is visible only to you until you register and publish it in the web console. See §11.

A 401 means the token is wrong, not the bundle — expired, revoked, or issued somewhere other than where you're deploying. Re-run starhive access with a fresh one.


9. What happens when someone installs your app​

An owner or admin installs your app, from the marketplace or from Settings → Apps. A member who finds it can only ask them to (§11.6). Starhive then sets up your data model in their workspace, in two stages — and the second stage is why your settingsPage matters.

install ─▶ INSTALLING ─▶ stage 1 ─▶ (required config filled in?) ─┬─ no ─▶ NEEDS_CONFIG
└─ yes ─▶ stage 2 ─▶ ACTIVE

Stage 1 creates your Space, your Types and their Attributes, your Workflows, and your seed objects.

Stage 2 resolves any ${config.*} bindings against what the admin chose, and flips the install to ACTIVE. It re-runs on every settings change, so an admin re-pointing a type in your settings page re-points the bindings too.

Install statusWhat it means for you
INSTALLINGSetup in progress.
NEEDS_CONFIGAn admin must fill in a required setting on your settings page before the app becomes active. Design that page to be usable in this state.
ACTIVEFully set up and running.
UNINSTALLEDSoft — the data is kept, so a reinstall picks up where it left off.
FAILEDSetup stopped partway.

The status is exactly these five values. The original table also listed UPGRADING and UPGRADE_FAILED — those don't exist; see the correction in Shipping a new version §7 for what an upgrade failure actually looks like. In practice, an install that fails early on more often leaves no installation behind at all, rather than remaining visibly stuck in a Failed state to resume from.

Everything is created as the installing admin, so your app can only provision what that person is allowed to create.

Check after your first install: the Space exists with the right name; types and attributes match your manifest; seed objects are there; and your settings page renders sensibly with nothing configured yet.


10. Test it yourself, then roll it out​

There are two pointers on your app:

  • latest deployed — moved by every deploy. Every developer of the app gets this version automatically, with no separate opt-in step.
  • published — moved only by promote. Every non-developer install resolves to this version — same method, same lines: anyone who isn't a developer of the app gets publishedVersion(app).

There is no separate "Receive development builds" toggle. Every developer of the app — on every workspace they install it into — always gets the latest deployed build, and every non-developer always gets the published version, unconditionally. There is no way for a non-developer's workspace to opt into pre-promotion builds, and no way for a developer to opt out and see what customers see. One related gotcha: the "Update available" signal compares an install's pinned version against the most recently deployed version of the app, regardless of promotion — so it does not by itself distinguish a promoted release from one still being tested. See Shipping a new version §4 for more.

That split is what makes releases safe:

# 0. Once: install the app in a workspace you own, as one of its developers.
# That workspace now follows every build you deploy — no separate toggle needed.

# 1. Ship a new build. Only your own developer workspaces see it; customers are untouched.
starhive app deploy --build

# 2. Reload it there and test properly. A build that changed the manifest needs one
# click of "Update" in Settings → Apps; one that didn't arrives on its own.

# 3. When you're happy, roll it out.
starhive app promote
starhive app promote [--manifest ./manifest.yaml]

promote reads app.key and app.version from your manifest, finds that deployed version, and makes it the one customers install and upgrade to. It has no effect on the latest-deployed pointer, so your own build is unaffected. The version must already be deployed successfully.

You can do the same from the CLI's interactive prompt: run starhive on its own, then /app for /list, /deploy and /promote. Both /deploy and /promote ask for confirmation; /list is read-only. Scaffolding is command-line only — there's no /create in app mode.

Don't skip step 2. Promote is the only irreversible-feeling step here: it changes what every customer gets on their next upgrade — and an upgrade applies your new data model to workspaces full of their objects. Test the upgrade path, not just a fresh install.


11. List it on the marketplace​

The CLI owns build, version and rollout. The web console (Marketplace → Manage your apps) owns identity, marketplace details, who may install, and who may develop. Starhive owns approval.

┌──────────────────────── CLI ────────────────────────┐
create ─▶ deploy ─▶ (visible only to you) ────────▶ promote (move the customer version)
└──────────────────────────────────────────────────────┘
│
▼ web console
Save as private ─▶ set details / support / availability ─▶ Make public
│
▼
PENDING_REVIEW ─▶ (Starhive review) ─▶ PUBLISHED

11.1 Statuses​

StatusWho can install itListed on the marketplace
DEPLOYEDNobodyNo — a bundle exists, but the app has no name or vendor yet.
PRIVATEYou and anyone you allowNo — off-market, but installable and manageable.
PENDING_REVIEWYouNo — you applied to be public and Starhive has not decided yet.
PUBLISHEDEveryone (subject to availability)Yes.
DECOMMISSIONEDNobodyNo — Starhive took it off the marketplace. Existing installs keep working; new ones are blocked, including yours.

Promoting a version takes effect immediately, every time — there is no review-gated status and no mechanism for a promotion to wait on Starhive's review. See Shipping a new version §6 for more.

11.2 Registering the app​

Marketplace → Create a new app takes an app you have already deployed and gives it an identity:

  • Vendor — your publisher name, unique, and reused across your apps.
  • App name — up to 60 characters. Changeable later; the key never is.
  • Save as private stops there: PRIVATE, installable by you, off-market.
  • Make public additionally requires a summary and at least one category, and submits the app for review (PENDING_REVIEW).

Categories are a fixed list: Data & Analytics, Design, Human Resourcing, Project Management, CRM, Security, Development. A logo (PNG, JPEG, SVG, WebP or GIF, up to 512 KB) is shown before your app's name everywhere it appears.

11.3 The tabs on your app​

TabWhat it holds
GeneralName and logo. Key, vendor and status are fixed.
DetailsSummary and categories — the marketplace listing.
VersionsEvery deployed version, newest first, with Promote beside each. The same thing starhive app promote does.
AvailabilityWho may install: every workspace, or only ones you list.
SupportOptional data-security statement, support ticketing and forum URLs for your listing.
DevelopersWho can deploy, promote and manage this app.

Availability is the setting for a partner shipping to named customers. Choose Only selected workspaces and add each customer's workspace ID — they have to send it to you, because you cannot look up other tenants' workspaces. It gates new installs only: removing a workspace later does not disturb the installation it already has. List nothing and nobody can install the app, which the screen warns you about.

Developers can each deploy new versions, promote them, and manage the app — and they receive Starhive's decisions on promoted versions. The owner (whoever first deployed it) cannot be removed, so an app can never end up with nobody able to ship it. Anyone on the list can add or remove anyone else, with that one exception.

starhive app developers # who can ship this app
starhive app developers --add dev@acme.com
starhive app developers --remove dev@acme.com

11.4 What a reviewer sees​

Worth knowing before you submit, because it is read by a person: Starhive's admin console renders what installing it creates in a workspace (space, types and their attributes, workflows, seed objects), where it renders and what it asks an admin for — plus your manifest as submitted.

A refusal comes back with a note saying what would have to change, and the app stays on the version customers already have. Fix it, deploy a new version, promote again.

11.5 Ratings and reviews​

Anyone who can see your app can rate it 1–5 with a comment. One review per person — reviewing again edits theirs — and the average and count are computed live on your listing. You can reply to any review as the developer; replies are labelled as such. A reader can flag a review as inappropriate, which records a marker for Starhive; nothing is hidden automatically.

11.6 When a member asks for your app​

Installing is an owner's or admin's act. A member who finds your app in the marketplace gets Ask an admin to install instead, and the request shows up in that workspace's Settings → Apps with who asked and when, next to Install and Dismiss. Installing the app — from there or from the marketplace — answers every pending request for it. Both the asking and the answer are notified.

11.7 Pricing​

Every app is free today. The marketplace listing has a Free/Paid marker and the install dialog has copy for a monthly or yearly price with a 30-day trial, but there is no way to set a price: no Pricing tab in the console, and the app-platform API has no pricing field to set. Everything ships as Free, and the paid path is scaffolding waiting on the rest of the work.

So: don't design around charging for the app itself yet, and don't tell a customer a price is coming through Starhive. If you need to charge, that conversation is currently outside the platform.


12. Versioning & release checklist​

The short version is below. Shipping a new version is the full account: how the deployed and published pointers move, what an upgrade applies to a customer's data model, and what deploy does and doesn't check.

Versioning rules

  • app.version in the manifest is the source of truth. Bump it for every change you intend to ship.

  • Re-deploying an unpublished version replaces its manifest and bundle in place — that is the develop loop.

  • The published version is immutable. Re-deploying it is refused with a message telling you to bump instead.

  • Nothing compares version numbers — the published pointer is wherever you last promoted. Use semver because customers read it.

  • Removing a type or attribute your app previously declared is not refused at deploy — deploy does not diff your manifest against what's published, and there is no deprecated field (§5.9). It just stops being managed by provisioning going forward, and whatever a workspace already has stays there untouched. See Shipping a new version §8-11 for the full account.

Before you promote

  • app.version bumped, and the build you're shipping is the one you tested.
  • data.types and the attribute names your code reads still match.
  • Installed cleanly into a fresh workspace — setup reached ACTIVE.
  • Every declared slot renders.
  • The settings page works with nothing configured, and saves without error.
  • Upgrade path checked from the currently published version, not just a fresh install.
  • Errors surface as toasts, not blank frames.
  • Works in light and dark.
  • Marketplace details, support URLs and pricing are current.

13. What the sandbox means for your code​

Your app runs in a locked-down iframe on an isolated origin. You don't have to configure any of it, but it constrains what you can write:

ConstraintPractical consequence
Your app can only call its own originNo third-party APIs, no analytics beacons, no script CDNs. There is no remote declaration to open an exception (§5.8) — bundle everything you need.
Your app holds no credentialYou never implement auth. You also can't act on behalf of anyone but the current user.
Data access is scoped to your appYou can read and write your own types in your own space. You can't reach unrelated workspace data, even if the user could.
Reads and writes run as the userTwo users can see different results from the same code. NOT_FOUND can mean "not visible to you". Test with a low-privilege account.
config.set is settings-page onlyDon't try to persist app state from other slots — use objects, or per-viewer browser storage.
The bundle is publicNo secrets, no keys, no hidden endpoints.
Only Starhive may frame your appIt won't run standalone; don't build flows that assume it can.

The policy, precisely. Your bundle is served from {appId}.apps.starhive.com/version/{v}.

DirectiveValueNotes
default-src'self'
script-src'self' 'unsafe-inline'No third-party script hosts. No mention of WebAssembly/'wasm-unsafe-eval' anywhere in the policy.
style-src'self' 'unsafe-inline'No Google Fonts or any other external stylesheet host.
font-src'self' data:Same — no external font host.
connect-src'self' (statically)There's a per-app widening mechanism at the infrastructure layer, but nothing populates it today — there's no way to add an external origin to connect-src from a manifest right now.
img-src'self' data: plus Starhive's media CDN (https://media.{domain})This part of the original claim was correct — confirmed at cloudfront.tf lines 21-24, 45.
frame-ancestorsThe Starhive host app's own origin, plus the two Atlassian/Jira embedding originsNot mentioned in the original guide at all — this is what actually enforces "only Starhive may frame your app," including when Starhive itself is embedded in a Jira page.

14. Troubleshooting​

SymptomLikely causeFix
✗ No Personal Access Token setNo token configured.starhive access, or set STARHIVE_TOKEN.
HTTP 401 on deploy or promoteToken expired, revoked, or issued for a different Starhive instance.Generate a fresh PAT and re-run starhive access.
Manifest not found / Bundle directory not foundWrong working directory or flags.Run from the app root, or pass --manifest / --dir.
Bundle directory is emptyYou didn't build.npm run build, or deploy with --build.
App … not found — deploy it firstapp.key doesn't match anything you've deployed.Check the key in your manifest, and that the deploy actually succeeded.
Version … not found — deploy it firstThat manifest version was never deployed.Deploy it, then promote.
App loads blank, spinner foreverThe bridge never connected — usually because you opened it outside Starhive.Open it inside Starhive, not on localhost:4500.
Assets 404 inside the iframebase: './' missing from vite.config.ts.Restore the relative base.
Deploy succeeded, nothing changedYou deployed but didn't promote — customers are on the published version.starhive app promote when you've tested it.
Deploy refused: "is published and cannot be overwritten"You re-deployed the version customers are running.Bump app.version, deploy, then promote.
Promote refused: "has no ready bundle"The bundle upload never finished for that version.Deploy it again, then promote.
Customers don't see your new buildYou deployed but didn't promote.starhive app promote.
FORBIDDEN on config.setCalled outside settingsPage.Only write config from the settings slot.
Query returns nothing, or BAD_REQUESTMissing or unrecognised typeKey.Pass a typeKey declared in data.types.
Install stuck at NEEDS_CONFIGA required config field is unset.Fill it in on the settings page; stage 2 re-runs automatically.
A user sees less than you doCorrect — your app runs as them.Not a bug. Handle NOT_FOUND gracefully.

Run with DEBUG=starhive for verbose CLI output.


15. Quick reference​

TaskCommand
Install the CLInpm install -g @starhive/cli
Save your PATstarhive access
Scaffold a new appstarhive app create "<name>" [--key <appKey>] [--dir <path>]
Buildnpm run build
Build + deploynpm run deploy
Deploystarhive app deploy --build
Roll out to customersstarhive app promote
List your appsstarhive → /app → /list
Manage apps interactivelystarhive → /app (/list, /deploy, /promote)
See who can ship this appstarhive app developers
Add / remove a developerstarhive app developers --add <email> / --remove <email>
Register it, set details, availabilityWeb console → Marketplace → Manage your apps
Test a build before promotingWeb console → Settings → Apps → Development builds

The whole journey in eight commands:

npm install -g @starhive/cli
starhive access
starhive app create "My App" --key com.acme.myapp
cd my-app && npm install
# …write your manifest and your UI…
npm run build
starhive app deploy # only you see it
starhive app promote # now customers do

16. Glossary​

  • Extension point / slot — a place in Starhive where your app renders: globalPage, objectPanel, widget, settingsPage. (Not macro — see §5.4.)
  • Module — one mountable surface in your manifest: a slot plus a route into your bundle.
  • Installation — your app installed in one workspace. Holds that workspace's space, ids and settings.
  • Bridge — the SDK and channel between your app and Starhive, published as @starhive/bridge.
  • Acting as the user — your app's requests run with the identity and permissions of whoever is looking at it.
  • Logical key — a name from your manifest ("item") that Starhive resolves to a real id per workspace.
  • Latest deployed vs published — what you get vs what customers get. deploy moves the first; promote moves the second. Every developer of the app is always on the first; see the correction at §10 — there is no separate opt-in toggle.
  • Install request — a member asking their admins to install your app, answered in Settings → Apps.
  • Transition — a permitted move between two workflow states, provisioned from data.workflows. Your app can read the moves available on an object and apply one with useTransitions and objects.update — see §6.5.

17. Where to go next​

Deploy the scaffold before you write anything. starhive app create gives you a deliberately minimal app — one global page, the SDK wired up, a manifest with nothing but an identity. Build it and deploy it as-is. It takes two minutes, it proves your token and your setup work, and it means that when something breaks later you know it was your change that broke it. Grow it from there using the manifest skeleton in §5.

Before your second version, read Shipping a new version. Changing a data model that customers already hold data in has rules, and they are enforced at deploy — better to know them before you have written the change.

The CLI documents itself. Every command and flag:

starhive --help
starhive app --help
starhive app deploy --help

Starhive's help centre covers the product itself: Spaces, Types, Attributes, StarQL, permissions and Personal Access Tokens. Worth reading the Concepts pages before you design data.types, since your app's data behaves exactly like everything else in Starhive.

Stuck, or missing a capability you need? Contact Starhive support. The app platform is deliberately small in its first version, and what gets built next is driven by what app developers run into — so a "we couldn't build X" is useful feedback, not a nuisance.


18. Open-source Starhive apps​

github.com/starhive-labs/starhive-public-apps