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 |
scopes and remotes don't existThe 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: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 }
macro module, a scopes/remotes block, orspace.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
| 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 |
macro slotThe 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
macro module described here does not existThe 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 }
space.access is not a manifest fieldThe 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:
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. |
includeChildren fieldThe 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
scopes does not existThe 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
remotes does not existThe 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 listsremotes-adjacent concepts ("egress") as a "DEFERRED section... tolerated and ignored" (AppManifest.kt:9), and there is noremotesfield onAppManifest.- There is no
bridge.remotes()/bridge.fetch()implementation, no per-remote credential store, and no"Connect <name>"settings UI found anywhere inapp-platform-serviceorstarhive-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.jsandapp-csp.tfin theinfrastructurerepo) that can widen an app version'sconnect-srcfrom a per-{appId}/{version}key-value store — written, per its own comments, by anapp-platform-servicecomponent calledBundleCsp.kt. That file does not exist inapp-platform-servicetoday (searched the whole service — no match), so nothing ever populates that store, and the widening code path is unreachable: every app gets the staticconnect-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
deprecated: true retirement mechanism, and deploy does notrefuse 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"]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. (Thebridge.resolveAttributeKeymethod 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.versionto ship changes. Verified against code atAppRegistryService.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.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 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
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()
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)
- Removed:
bridge.workflow.transitions(...), theoptions.transitionsargument onobjects.update,bridge.remotes(),bridge.fetch(...),bridge.macro.setState(...), andbridge.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 thetypeKeyToId/attributeKeyToIdmaps 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')
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.
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 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:
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).
| 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
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:
| 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. |
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.
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) returnslatestVersion(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 getspublishedVersion(app).
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.
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
| 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. |
PUBLISHED_PENDING_REVIEW does not existThe 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
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.
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.
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
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.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.
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.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}.
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.
| 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) | 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-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. |
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
| 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. |
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: truefix — 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 —remotesdoesn'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
| 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 (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 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. 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.
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. 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.
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.