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
Corrected — scopes and remotes don't exist

The original table included scopes (read access to a customer-nominated type) and remotes (an egress path to systems outside Starhive) as manifest blocks you could declare. Neither exists. AppManifest.kt's own doc comment says it plainly: "DEFERRED sections (scopes, lifecycle, functions, egress) are tolerated and ignored" (AppManifest.kt:9), and the AppManifest data class (AppManifest.kt:13-19) has no scopes or remotes field — only app, ui, modules, config, data. A manifest with either block parses fine (unknown keys are ignored via @JsonIgnoreProperties(ignoreUnknown = true)) but the block is silently dropped; nothing reads it. There is also no code anywhere in app-platform-service that writes a per-app CSP connect-src widening from a manifest field — see §13 for what does exist at the infrastructure layer and why it can't be reached from a manifest today. Both blocks are removed from every example below; see the full explanation at §5.7 and §5.8.

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 }
Corrected — this skeleton no longer shows a macro module, a scopes/remotes block, or

space.access The original skeleton also showed a macro module under modules, scopes:/remotes: blocks after data:, and an access: open field on space. None of these exist in the parsed manifest schema. SpaceDef (AppManifest.kt:85) has exactly two fields, key and name — no access. See §5.4, §5.6, §5.7 and §5.8 for the full evidence on each. They've been removed rather than left in as "aspirational" YAML, since a manifest that includes any of them today simply has the field silently ignored (AppManifest.kt:9, @JsonIgnoreProperties), not deferred or queued.

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
Corrected — there is no fifth macro slot

The original table listed a macro slot alongside these four. Modules (AppManifest.kt:34-47) has exactly four lists — globalPage, objectPanel, widget, settingsPage — and ModuleType (AppManifest.kt:49) is GLOBAL_PAGE, OBJECT_PANEL, WIDGET, SETTINGS_PAGE, four values, no MACRO. See §5.4 for what the original macro section described and why it's been replaced.

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​

Corrected — the macro module described here does not exist

The original version of this section (and this guide's manifest skeleton, module table, bridge context, bridge API, troubleshooting table and glossary) described a fifth extension point: a block a writer could insert into a rich-text page from the editor's / menu, with its own params, height clamp (40–2000px), useAutoResize hook, and macroState/macroStateWritable/ bridge.macro.setState storage.

None of it exists. Modules (AppManifest.kt:34-47) has no macro list, and ModuleType (AppManifest.kt:49) has no MACRO value — the enum is GLOBAL_PAGE, OBJECT_PANEL, WIDGET, SETTINGS_PAGE. There is no MacroParam type, no height-clamping logic, and (see §6) no confirmed useAutoResize/useMacroState/bridge.macro.setState/bridge.resize in any bridge SDK surface this pass could find. The whole feature has been removed from this guide rather than kept as a described-but-unbuilt capability, since a developer trying to declare a macro module today would have it silently dropped by the manifest parser (@JsonIgnoreProperties(ignoreUnknown = true)) with no error and no effect.

If you need something like this — an app surface embedded directly in a document rather than a panel/widget/page — it isn't available in the app platform today, as far as this pass could confirm.

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 }
Corrected — space.access is not a manifest field

The original text here said access (open/restricted) decides who can read the space, with restricted as the default. Verified against code: SpaceDef (AppManifest.kt:85) has exactly two fields, key and name — no access. A manifest declaring it has the field silently ignored. Who can read the space once created is presumably whatever Starhive's own default is for a newly created Space, changeable afterward by an admin in the space's own permissions (the same mechanism Shipping a new version already points to for widening access later) — but this pass could not confirm the actual default from app-platform-service, since it calls space-manager's createSpace with only a name.

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.
Corrected — no includeChildren field

The original table also listed includeChildren on REFERENCE (defaulting to true, so subtypes of the target count; set false to demand the exact type). Verified against code: AttributeDef (AppManifest.kt:127-139) has no includeChildren field — only key, name, type, required, cardinality, targetType, default, config, workflow. This claim is also removed from §1.3 and from the "what deploy refuses" table in Shipping a new version.

| 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​

Corrected — scopes does not exist

The original version of this section described a scopes.read manifest block that let an app declare read access to a customer-nominated type (bound through a typeRef config field, with a reason shown to the installing admin). This does not exist.

AppManifest.kt's own doc comment lists scopes explicitly as a "DEFERRED section... tolerated and ignored" (AppManifest.kt:9), and the AppManifest/DataSection data classes have no scopes field at all. There is no scope-checking code anywhere in app-platform-service.

What this means in practice: your app can read and write the types it provisions itself (data.types), and nothing else. There is no declared mechanism today for an app to read a customer's own pre-existing data outside its own provisioned Space — as far as this pass could confirm. 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​

Corrected — remotes does not exist

The original version of this section described, in detail, a remotes manifest block letting an app declare an external system, have an admin connect it with a stored credential, and call it via bridge.remotes() / bridge.fetch() — either proxied through Starhive (access: proxy, the default) or called directly from the iframe (access: client, widening the bundle's CSP connect-src). None of it is implemented.

  • AppManifest.kt's doc comment lists remotes-adjacent concepts ("egress") as a "DEFERRED section... tolerated and ignored" (AppManifest.kt:9), and there is no remotes field on AppManifest.
  • There is no bridge.remotes()/bridge.fetch() implementation, no per-remote credential store, and no "Connect <name>" settings UI found anywhere in app-platform-service or starhive-client.
  • The one real piece of infrastructure that is there, and is worth knowing about if you're designing around this gap: the CloudFront distribution that serves app bundles has a viewer-response function (terraform/modules/app_bundles/app-csp-function/index.js and app-csp.tf in the infrastructure repo) that can widen an app version's connect-src from a per-{appId}/{version} key-value store — written, per its own comments, by an app-platform-service component called BundleCsp.kt. That file does not exist in app-platform-service today (searched the whole service — no match), so nothing ever populates that store, and the widening code path is unreachable: every app gets the static connect-src 'self' policy with no exceptions. In other words, the edge plumbing was built ahead of the manifest feature that was meant to drive it, and the manifest feature was never built.

What this means in practice: your iframe can reach its own origin and nothing else, with no declared way to widen that today. A Starhive app cannot reach an external system as far as this pass could confirm — don't design around a remotes-style escape hatch existing.

5.9 Rules and gotchas​

Corrected — there is no deprecated: true retirement mechanism, and deploy does not

refuse breaking changes The original bullet here said nothing in data can ever be deleted once published, and pointed you at a deprecated: true field to retire it instead. Neither the field nor the refusal exists: AttributeDef/WorkflowStateDef/TypeDef (AppManifest.kt) have no deprecated field, there are zero matches for "deprecated" anywhere in app-platform-service, and AppRegistryService.deploy() (AppRegistryService.kt:86-134) does not compare a new manifest against the previously-published one at all — it only checks whether this exact version number is already published (immutable) and refuses re-deploying that one specific version (AppRegistryService.kt:112-117). 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 correction of the release-model narrative built on this.

  • 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. (The bridge.resolveAttributeKey method itself is part of the bridge SDK surface this pass could not independently verify — see §6.3.)
  • 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. Verified against code at AppRegistryService.kt:112-117. See Shipping a new version.
  • Verified against code — the manifest is validated on deploy (ManifestParser.kt), 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 caution 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​

Could not verify against code

The @starhive/bridge SDK is described in this guide as vendored/separate from the app-platform backend, and that held up: an exhaustive search of starhive-client (every file under apps/platform-ui, including all locale/i18n string files) found zero matches for bridge, iframe, marketplace, app-platform, or AppFrame — the frontend embedding described here was not found in that repo. starhive-development also has no bridge SDK source (it's frontend code), but it does have an internal design doc, docs/app-framework-guide.md (dated 2026-06-25, RFC/design status — itself not the live SDK and possibly stale relative to today), which describes an earlier or narrower version of this API and says the bridge SDK's real source lives in packages/bridge/src/{index.ts,bridge.ts,protocol.ts,react.tsx} in a separate frontend repo not available in this pass. Below, the hooks/methods that also appear in that internal doc are noted as cross-referenced (not independently verified against running code); anything below that doesn't appear there at all is flagged individually.

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()
Corrected

The original text here said a key your app "deprecated" is withheld from these maps. There is no deprecation mechanism (see §5.9), so nothing is ever withheld today — every key your manifest has ever declared stays in these maps once provisioned. There is no retirement path for a key.

6.3 The API surface​

Hooks — cross-referenced against docs/app-framework-guide.md: useStarhiveContext, useTheme, useObjects, useObjectQuery, useConfig, useType, useTypes, useToast, useNavigate, useBridge.

Hooks named in the original guide but not found in that internal doc either — unconfirmed, not necessarily wrong: useObject (singular), useAttribute, useAttributes, useTypeById.

Removed — tied to features confirmed not to exist: useTransitions (see the caution in §6.5), useMacroState, useAutoResize (macro-only — see §5.4).

Imperative (const bridge = useBridge()) — cross-referenced against the same internal doc:

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.getType(typeKey) // full schema
bridge.listTypes({ spaceId? }) // light type refs, for pickers

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

bridge.toast(message, variant?) // 'success' | 'error' | 'info' | 'warning'
bridge.navigate(to)
Corrected / could not verify
  • Removed: bridge.workflow.transitions(...), the options.transitions argument on objects.update, bridge.remotes(), bridge.fetch(...), bridge.macro.setState(...), and bridge.resize(...). The remotes/macro-tied ones are gone because those features don't exist (§5.4, §5.8). The workflow-transition ones are gone because the one internal design doc this pass could find that describes the real bridge (docs/app-framework-guide.md, 2026-06-25) says so explicitly: "the v1 bridge has no workflow-transition method... An app can read a WORKFLOW value, but advancing status from inside the app isn't supported yet... App-driven transitions are a planned bridge addition." Nothing in the accessible code confirms this shipped since. See §6.5.
  • Unconfirmed, kept: bridge.resolveTypeKey/bridge.resolveAttributeKey. These don't appear in the internal doc's list either, but they're a plausible, minimal wrapper over the typeKeyToId/ attributeKeyToId maps that are confirmed to exist (InstallationEntity.kt), so they've been kept rather than deleted on a guess — treat them as unconfirmed until checked against a live instance.

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 — could not verify.

Could not verify against code

The original guide showed a two-step pattern here — a useTransitions hook to fetch the available moves, then objects.update(...) with a { transitions: { [attributeId]: transitionId } } option to write the new state and the transition atomically — and presented it as the only supported way to change a WORKFLOW attribute's value.

The one internal source this pass could find describing the real bridge SDK (docs/app-framework-guide.md in starhive-development, dated 2026-06-25) says the opposite is true for v1: "the v1 bridge has no workflow-transition method... An app can read a WORKFLOW value, but advancing status from inside the app isn't supported yet — users change it from Starhive's native object view, and the app shows it read-only. App-driven transitions are a planned bridge addition." Nothing in the accessible codebase (which doesn't include the bridge SDK's own source — see the caution at the top of §6) confirms this shipped in the roughly three months since that doc was written. Treat the whole pattern above — the hook, the objects.update transitions option, and the workflow-related troubleshooting rows and glossary entry elsewhere in this guide — as unconfirmed rather than working code, and check against a live instance before building on it.

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:

Could not verify against code

The token categories in this table (colorScheme, colors.*, radius, spacing, fontSizes, fontWeights, shadows) are corroborated at a category level by docs/app-framework-guide.md (an internal design doc in starhive-development, dated 2026-06-25): "context.theme (StarhiveTheme) carries design tokens — colorScheme, colors.* (primary, surface, text, border, positive/warning/negative …), radius, spacing, fontSizes, fontWeights, shadows." That same doc also describes a widget's iframe getting background/surface as transparent and text set to the dashboard cell's own foreground color — matching the "widget case" section below closely. Beyond that category-level match, the exact sub-fields (primaryHover, onPrimary, textMuted) and scale values (sm/md/lg, xs...xl) are not independently confirmed — that internal doc doesn't go into that detail, and this pass found no theme-definition source in either accessible repo (the frontend code that would define StarhiveTheme was not found in starhive-client — see the caution at the top of §6).

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​

Cross-referenced against an internal doc

This exact set of seven codes — TIMEOUT, BAD_REQUEST, FORBIDDEN, NOT_FOUND, UNKNOWN_METHOD, INTERNAL, CHANNEL_CLOSED — also appears in docs/app-framework-guide.md (internal design doc, starhive-development, 2026-06-25): "Error codes: BAD_REQUEST, FORBIDDEN, NOT_FOUND, UNKNOWN_METHOD, INTERNAL, CHANNEL_CLOSED, TIMEOUT." Same seven codes, matching exactly. That doc isn't the live SDK source either, so this is corroboration rather than a direct code check, but it's a strong match.

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.

Verified against code at InstallationStatus (Common.kt:62): the enum 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 does, as best this pass could determine it.

Could not verify against code

FAILED is declared in the enum but this pass found no code path in app-platform-service that ever assigns it — InstallationService.kt and ProvisioningService.kt only ever set INSTALLING, NEEDS_CONFIG, ACTIVE, or UNINSTALLED. install() and provisionStageOne()/finalizeBindings() run inside the same @Transactional scope, so a failure partway through rolls back the whole database transaction (per the note in ProvisioningService.kt:29-30 about gRPC writes not being rolled back with it) — meaning a first-install failure likely leaves no installation row at all, rather than one sitting at FAILED with "what was already created" to resume from, as the original text claimed. Treat this row as declared-but-unconfirmed rather than working as described.

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. Verified against code: every developer of the app gets this version automatically — AppRegistryService.resolveInstallVersion() (AppRegistryService.kt:556-561) returns latestVersion(app) whenever the caller is one of the app's developers, 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).
Corrected — there is no separate "Receive development builds" toggle

The original note here said the opposite of what the code does: that being a developer does not put you on your own builds unless a per-workspace "Receive development builds" toggle is turned on. No such toggle exists. InstallationEntity.kt and AppEntity.kt have no field for it, and resolveInstallVersion() (AppRegistryService.kt:556-561) is unconditional: 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. There is no way found in this pass 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. The "Update available" signal (InstallationApiModels.kt:38-44) is also worth knowing about here: it compares an install's pinned version against AppRegistryService.latestVersionOrNull(), which — despite its own doc comment saying "latest published version" — actually returns the most recently deployed version of the app regardless of promotion (AppRegistryService.kt:504-507), so it does not by itself distinguish a promoted release from one still being tested. See Shipping a new version §4 for the same correction.

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.

Could not verify against code

Everything backend-side in this section — registering an app, making it public, availability, ratings/reviews, install requests — is backed by real, working endpoints in app-platform-service (AppController.kt, AppRegistryService.kt, ReviewService.kt). This pass could not, however, find the frontend console UI that would expose it: a search of starhive-client for marketplace, app-platform, bridge, iframe and AppFrame — across every component under apps/platform-ui/src and every locale's UI-string file — turned up nothing. That doesn't prove the console doesn't exist (it may live in a repo or branch not available in this pass), but it means the "Marketplace → Manage your apps" / "Settings → Apps" screens described throughout §11 (and the "Receive development builds" UI referenced in §10) should be treated as unconfirmed against a live product, distinct from the backend calls behind them, which are confirmed.

┌──────────────────────── 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.
Corrected — PUBLISHED_PENDING_REVIEW does not exist

The original table had a sixth status, PUBLISHED_PENDING_REVIEW, for a live app whose newer version was "waiting on Starhive" because it widened remote reach. Verified against code: AppStatus (Common.kt:20) is exactly DEPLOYED, PRIVATE, PENDING_REVIEW, PUBLISHED, DECOMMISSIONED — five values. There's also no mechanism for it to plug into: AppRegistryService.promote() (AppRegistryService.kt:527-540) unconditionally repoints publishedVersionId once the version is developer-owned and its bundle is READY — no review-gating logic exists, and it couldn't check "reaches further outside Starhive" in any case since remotes isn't a real manifest field (§5.8). Promoting a version today takes effect immediately, every time. See Shipping a new version §6 for the full correction.

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.

Could not verify against code

The backend does have a developer concept — AppDeveloperEntity/AppDeveloperRepository, checked throughout AppRegistryService.kt for who may deploy/promote/manage an app — and the first developer is granted automatically on an app's first deploy (AppRegistryService.kt:104). But this pass found no HTTP endpoint anywhere in app-platform-service's five controllers (AppController, AppAdminController, InstallationController, ReviewController, VendorController) that lists, adds, or removes a developer after that — developerRepository is only ever read internally for permission checks. That means the CLI command below, and the Developers tab's add/remove UI, are unconfirmed rather than confirmed: they may be real and simply call a route this pass missed, or the client side may handle this some other way, but no backend support for managing developers beyond the automatic first-deploy grant was found. (The CLI binary itself isn't in either accessible repo, matching the same caveat on Starhive CLI.)

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.

Corrected

The original text also said the reviewer sees "what it reaches outside Starhive (each remote, its address, how it is reached)" and that "every reason you wrote is quoted there." Both describe the remotes feature, which doesn't exist (§5.8) — there is nothing for a reviewer to see on that front today.

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​

Verified against code

AppRegistryService's AppDetails/requirePublishable (AppRegistryService.kt:40-46, 575-583) only has fields and requirements for summary, categories, dataSecurityUrl, supportTicketUrl and forumUrl — no pricing field anywhere on the app-platform side. Matches the claim below.

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.
Corrected

The original bullet here said a breaking data-model change is refused at deploy, and that you retire things with deprecated: true. Neither is true — see the correction at §5.9 and the full account in Shipping a new version §8-11: deploy does not diff your manifest against what's published, and there is no deprecated field. Removing a type or attribute your app previously declared is not refused; it just stops being managed by provisioning going forward, and whatever a workspace already has stays there untouched.

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}.

Corrected — the CSP below is rewritten from the actual infrastructure config

The original table claimed Google Fonts access on style-src/font-src and 'wasm-unsafe-eval' on script-src. Neither is in the real policy. The table below is taken directly from the deployed CloudFront response-headers policy (terraform/modules/app_bundles/cloudfront.tf, the bundle_csp local, in the infrastructure repo), which is mirrored locally in BundleContentController.kt:68-69 in app-platform-service.

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)See the caution below — there's a real per-app widening mechanism at the infra layer, but nothing populates it.
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.
Could not verify against code

The infrastructure does contain a mechanism to widen connect-src per app version — a CloudFront viewer-response function (app-csp-function/index.js) that reads extra origins from a per-{appId}/{version} key-value store. But per that function's own comments, the store is meant to be written by an app-platform-service component called BundleCsp.kt on deploy — and that file doesn't exist in app-platform-service today. So the widening code is unreachable: nothing ever writes an entry, every app gets the static policy above, and there is no way to add a client-style remote origin to connect-src from a manifest right now.


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.
Corrected — rows removed

Four rows from the original table described features that don't exist and have been removed rather than corrected in place:

  • "Deploy refused: 'would break workspaces running v…'" and its deprecated: true fix — deploy never diffs against the previously-published manifest (§5.9), so this refusal doesn't happen.
  • "Promote says it's waiting on Starhive" — there is no review-gated promote (§11.1).
  • Two bridge.fetch/remote rows — remotes doesn't exist (§5.8).

Two more rows described the macro slot (rendering-at-wrong-height, "cannot be shown") and are removed with it (§5.4). The WORKFLOW-transition-refusal row and the "transition isn't offered" / "status button stops working" rows are removed too, pending the same bridge-workflow-transitions question flagged in §6.5 — if that capability doesn't exist, these specific error messages and useTransitions behavior can't be confirmed either.

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 app (unconfirmed — see §11.3)starhive app developers
Add / remove a developer (unconfirmed — see §11.3)starhive app developers --add <email> / --remove <email>
Register it, set details, availability (console UI unconfirmed — see §11)Web 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. Its own source wasn't found in either accessible repo — see the caution at the top of §6.
  • 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. Whether an app can write one from inside the bridge (rather than just read the current state) is unconfirmed — see the caution at §6.5.
Corrected — two entries removed

Macro params and Remote are removed: both described features that don't exist (macro module and remotes manifest block — §5.4, §5.8).


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