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:
- 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. - 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.
- 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 declare | Starhive gives you |
|---|---|
app | A stable identity, versioning, and an immutable version history |
modules | Placement at four extension points, sandboxing, theming |
config | A typed admin-settings store per install — no database of your own |
data.space / data.types | Real Spaces, Types and Attributes — searchable, reportable, permissioned |
data.workflows | Status state machines on your types |
data.seed | Starter 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:scopesandremotes, 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
REFERENCEattribute targets exactly one type, named bytargetType. (AttributeDefhas noincludeChildrenfield — see §5.6.) - No install-time hooks or event subscriptions. Your app runs when someone opens it, and only then.
2. Prerequisites
| Requirement | Notes |
|---|---|
| Node.js ≥ 18 | To build your app. Node 20+ recommended. |
| A Starhive account and a workspace | One 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 CLI | starhive — 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.
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 key | Env var | Default | Purpose |
|---|---|---|---|
token | STARHIVE_TOKEN | (unset) | Your PAT, sent as Authorization: Bearer …. |
appPlatformUrl | STARHIVE_APP_PLATFORM_URL | https://api.starhive.com | The 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 / flag | Default | Description |
|---|---|---|
<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: './'invite.config.tsis 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/uiand@starhive/themearen't published to npm yet, so their source is copied intosrc/and aliased invite.config.tsandtsconfig.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
| Field | Required | Notes |
|---|---|---|
key | yes | Globally unique, dot notation. Permanent — this is your app. |
version | yes | Semver. Every new value you deploy becomes an immutable version. |
description | no | One line, shown in listings. |
icon | no | Icon name. |
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
| Slot | Where it renders | What you additionally get |
|---|---|---|
globalPage | A full-page destination — "where the app starts" | workspace, user |
objectPanel | A panel/tab on a specific object | objectId — the object being viewed |
widget | A widget on sites/dashboards (transparent background) | workspace |
settingsPage | Your app's admin settings form | the 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:
type | Extra fields | Notes |
|---|---|---|
TEXT | — | Plain text. Usually the labelAttribute. |
DATE | — | YYYY-MM-DD. |
DECIMAL | — | Number. |
BOOLEAN | — | True/false. |
USER | — | A Starhive user reference. |
OPTION | config: { options: [A, B], multi: true } | Enumerated choices. Options go inside config:. |
REFERENCE | targetType: (a type key or ${config.*}), cardinality: one | Link to another type. Omit cardinality for many. Always matches the target type exactly — there's no includeChildren toggle to also include its subtypes. |
WORKFLOW | workflow: <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: trueretirement mechanism, and deploy does not refuse breaking changes. Nothing indatais 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"]andbridge.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.versionto 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.keymust be dot-notation and non-blank,app.versionnon-blank, at least one module declared, every${config.*}-bound attribute must reference a config field that actually exists, everyseedkey must reference a declared type, and every workflow must have at least one state, no duplicate state keys, exactly oneinitialstate, and every transition'sfrom/toand everyWORKFLOWattribute'sworkflowmust 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')
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 fromsettingsPage.
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:
| Token | Meaning |
|---|---|
colorScheme | 'light' or 'dark' — the scheme everything below is already resolved for |
fontFamily | The product font stack |
colors.primary / primaryHover / onPrimary | Brand/action color, its hover shade, and the foreground to put on it |
colors.background | Page background |
colors.surface | Card, input and panel background |
colors.text / textMuted | Primary and secondary text |
colors.border | Hairline borders |
colors.positive / warning / negative | Status colors |
radius | sm md lg |
spacing | xs sm md lg xl |
fontSizes | xs sm md lg xl |
fontWeights | normal semiBold bold |
shadows | sm 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'andcolors.surface: 'transparent'colors.textset 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, notrgba(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.htmlsetsbody { 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
- 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.
- 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.
- Give the dashboard cell a non-default appearance colour. Your widget's text should follow it.
- Reload in dark mode and watch the first paint for a white flash.
- Check disabled inputs, placeholders, focus rings and scrollbars — the details that fall back to
browser defaults when
color-schemeisn't set.
6.8 Errors
Every bridge call can reject with a StarhiveBridgeError carrying a stable code:
| Code | Usual cause |
|---|---|
TIMEOUT | No response within 15 s. |
BAD_REQUEST | Malformed args — e.g. a query with no typeKey. |
FORBIDDEN | config.set outside settingsPage, or touching data outside your app's scope. |
NOT_FOUND | The object or type doesn't exist, or isn't visible to this user. |
UNKNOWN_METHOD | Your vendored SDK is newer than the Starhive version you're running against. |
INTERNAL | Starhive-side failure. |
CHANNEL_CLOSED | The 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:
- Get the manifest right first and deploy once — provisioning shapes everything else.
- Install the app in a test workspace and keep that tab open.
- Build + deploy, reload, repeat.
- Use
toast()liberally while developing; a failed bridge call is otherwise invisible. - 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]
| Flag | Default | Purpose |
|---|---|---|
--dir <path> | ./build | The built bundle directory to upload. |
--manifest <path> | ./manifest.yaml | Your manifest. |
--build | (off) | Run npm run build first. |
--api <url> | https://api.starhive.com | Deploy 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 status | What it means for you |
|---|---|
INSTALLING | Setup in progress. |
NEEDS_CONFIG | An 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. |
ACTIVE | Fully set up and running. |
UNINSTALLED | Soft — the data is kept, so a reinstall picks up where it left off. |
FAILED | Setup 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 getspublishedVersion(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
| Status | Who can install it | Listed on the marketplace |
|---|---|---|
DEPLOYED | Nobody | No — a bundle exists, but the app has no name or vendor yet. |
PRIVATE | You and anyone you allow | No — off-market, but installable and manageable. |
PENDING_REVIEW | You | No — you applied to be public and Starhive has not decided yet. |
PUBLISHED | Everyone (subject to availability) | Yes. |
DECOMMISSIONED | Nobody | No — 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
keynever 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
| Tab | What it holds |
|---|---|
| General | Name and logo. Key, vendor and status are fixed. |
| Details | Summary and categories — the marketplace listing. |
| Versions | Every deployed version, newest first, with Promote beside each. The same thing starhive app promote does. |
| Availability | Who may install: every workspace, or only ones you list. |
| Support | Optional data-security statement, support ticketing and forum URLs for your listing. |
| Developers | Who 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.versionin 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 —
deploydoes not diff your manifest against what's published, and there is nodeprecatedfield (§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.versionbumped, and the build you're shipping is the one you tested.data.typesand 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:
| Constraint | Practical consequence |
|---|---|
| Your app can only call its own origin | No 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 credential | You never implement auth. You also can't act on behalf of anyone but the current user. |
| Data access is scoped to your app | You 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 user | Two 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 only | Don't try to persist app state from other slots — use objects, or per-viewer browser storage. |
| The bundle is public | No secrets, no keys, no hidden endpoints. |
| Only Starhive may frame your app | It 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}.
| Directive | Value | Notes |
|---|---|---|
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-ancestors | The Starhive host app's own origin, plus the two Atlassian/Jira embedding origins | Not 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
| Symptom | Likely cause | Fix |
|---|---|---|
✗ No Personal Access Token set | No token configured. | starhive access, or set STARHIVE_TOKEN. |
HTTP 401 on deploy or promote | Token expired, revoked, or issued for a different Starhive instance. | Generate a fresh PAT and re-run starhive access. |
Manifest not found / Bundle directory not found | Wrong working directory or flags. | Run from the app root, or pass --manifest / --dir. |
Bundle directory is empty | You didn't build. | npm run build, or deploy with --build. |
App … not found — deploy it first | app.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 first | That manifest version was never deployed. | Deploy it, then promote. |
| App loads blank, spinner forever | The bridge never connected — usually because you opened it outside Starhive. | Open it inside Starhive, not on localhost:4500. |
| Assets 404 inside the iframe | base: './' missing from vite.config.ts. | Restore the relative base. |
| Deploy succeeded, nothing changed | You 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 build | You deployed but didn't promote. | starhive app promote. |
FORBIDDEN on config.set | Called outside settingsPage. | Only write config from the settings slot. |
Query returns nothing, or BAD_REQUEST | Missing or unrecognised typeKey. | Pass a typeKey declared in data.types. |
Install stuck at NEEDS_CONFIG | A required config field is unset. | Fill it in on the settings page; stage 2 re-runs automatically. |
| A user sees less than you do | Correct — your app runs as them. | Not a bug. Handle NOT_FOUND gracefully. |
Run with DEBUG=starhive for verbose CLI output.
15. Quick reference
| Task | Command |
|---|---|
| Install the CLI | npm install -g @starhive/cli |
| Save your PAT | starhive access |
| Scaffold a new app | starhive app create "<name>" [--key <appKey>] [--dir <path>] |
| Build | npm run build |
| Build + deploy | npm run deploy |
| Deploy | starhive app deploy --build |
| Roll out to customers | starhive app promote |
| List your apps | starhive → /app → /list |
| Manage apps interactively | starhive → /app (/list, /deploy, /promote) |
| See who can ship this app | starhive app developers |
| Add / remove a developer | starhive app developers --add <email> / --remove <email> |
| Register it, set details, availability | Web console → Marketplace → Manage your apps |
| Test a build before promoting | Web 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. (Notmacro— 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.
deploymoves the first;promotemoves 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 withuseTransitionsandobjects.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.