Groundfloor Docs

Feature flags

Per-environment typed feature flags — manage them in the portal or with gf flags, promote them across environments, and read them at runtime from Shell apps and Coderunners.

Feature flags store typed toggles and config values (boolean, string, number, or JSON) per workspace environment. Use them to gate behavior without redeploying, then promote values from dev → staging → prod.

Building an app or agent workflow? Use Feature Flags for toggles. Do not create a "flags" collection in Data Vault or read toggles from .env. Shell apps read flags with useFlag() from the starter kit; Coderunners read them with an M2M token. See Read flags at runtime.

Who can access

ActionWho
List / get / compare flagsWorkspace read (owner, admin, writer, member) or an M2M token
Create / update / promoteWorkspace write (owner, admin, writer). M2M tokens are read-only on flags.
DeleteWorkspace delete (owner, admin, writer)
Read at runtime (GET …/flags/runtime)Shell end users with a site role, builders, or M2M tokens

Prerequisites

  • Workspace selected in the top navigation picker
  • At least one active environment. When you don't pick one, the workspace default is used: the active environment named dev, or the only active environment.

Manage flags

Open Feature flags

In the sidebar under Build, click Feature flags.

Select workspace and environment

Flags are scoped to one environment at a time.

Add or edit a flag

Choose a key, type and value, then save. Keys are lowercased and must start with a letter, followed by up to 62 lowercase letters, digits, -, _ or . (for example new-checkout, ui.dark_mode). compare, promote and runtime are reserved.

From the CLI

gf flags ls -e dev                               # -e: environment id or name
gf flags set new-checkout true -e dev            # boolean (inferred)
gf flags set max-items 25 -e dev                 # number (inferred)
gf flags set banner '{"text":"Hi"}' --type json -e dev
gf flags get new-checkout -e prod
gf flags compare dev prod
gf flags promote dev prod --dry-run              # then without --dry-run
gf flags rm new-checkout -e dev

Agents using the Groundfloor MCP can call flags_list and docs_get with id feature-flags.

Read flags at runtime

Every runtime reads the same endpoint:

GET /v1/workspaces/{workspace_id}/flags/runtime?environment_id={environment_id}
{
  "workspace_id": "7965d229-…",
  "environment_id": "0b1e…",
  "flags": { "new-checkout": true, "max-items": 25 }
}
Shell federated app (browser)Coderunner (server)
TokenShell end-user JWT: await window.__GF_GET_ACCESS_TOKEN__()M2M token (M2M tokens, gf m2m token)
PermissionAny role from your app's sign-in, built-in or custom. A signed-in user with no role gets 403, same as Vault. No per-flag Access grant is needed.Workspace read (every M2M client has it). Workload-identity Vault JWTs are rejected.
Environmentwindow.__GF_ENVIRONMENT_ID__, the environment the app is bound to, published by the Shell before your app mountsGROUNDFLOOR_ENVIRONMENT_ID, injected on deploy (the Coderunner's bound environment)
HelperStarter kit src/lib/flags.ts → useFlag('key', fallback)fetch (example below)

When environment_id is omitted, Groundfloor uses the workspace default environment (usually dev) and echoes the resolved id in the response.

Caching. Responses carry a weak ETag and Cache-Control: private, no-cache. Send If-None-Match with the last ETag to get 304 Not Modified with no body. Poll no more than every 30 seconds; the starter kit polls every 60 seconds. There is no push channel yet, so a change takes effect on the next poll.

Flag values are not secrets. Anything …/flags/runtime returns is visible to every signed-in user of the workspace. Keep credentials in Secrets.

Shell app (React)

The starter kit (gf apps init) ships src/lib/flags.ts:

import { useFlag, useFlags } from './lib/flags';

export function Checkout() {
  const newCheckout = useFlag('new-checkout', false);
  const maxItems = useFlag('max-items', 10);
  return newCheckout ? <NewCheckout max={maxItems} /> : <LegacyCheckout />;
}

// Several flags at once — one request, one poller:
export function Toolbar() {
  const { flags, loading, error } = useFlags();
  if (loading) return null;
  return flags['ui.dark_mode'] ? <DarkToolbar /> : <Toolbar />;
}

On errors the hooks keep the last good values (or your fallback), so a flag outage never crashes the app. In local Dev Shell window.__GF_ENVIRONMENT_ID__ is unset and the default environment is used; set VITE_GF_ENVIRONMENT_ID in .env.local to pin another one.

Coderunner (Node)

Attach an M2M client to the Coderunner. Deploy injects CONTROLPLANE_URL, GROUNDFLOOR_WORKSPACE_ID, the M2M client id and secret, and GROUNDFLOOR_ENVIRONMENT_ID.

const CP = process.env.CONTROLPLANE_URL;
const WS = process.env.GROUNDFLOOR_WORKSPACE_ID;
const ENV = process.env.GROUNDFLOOR_ENVIRONMENT_ID;

let token = { value: null, expiresAt: 0 };
async function m2mToken() {
  if (token.value && Date.now() < token.expiresAt - 30_000) return token.value;
  const res = await fetch(`${CP}/v1/workspaces/${WS}/m2m/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: process.env.GROUNDFLOOR_M2M_CLIENT_ID,
      client_secret: process.env.GROUNDFLOOR_M2M_CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`m2m token ${res.status}`);
  const body = await res.json();
  token = { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
  return token.value;
}

let cache = { etag: null, flags: {}, checkedAt: 0 };
export async function getFlags() {
  if (Date.now() - cache.checkedAt < 30_000) return cache.flags;
  const qs = ENV ? `?environment_id=${encodeURIComponent(ENV)}` : '';
  const res = await fetch(`${CP}/v1/workspaces/${WS}/flags/runtime${qs}`, {
    headers: {
      Authorization: `Bearer ${await m2mToken()}`,
      ...(cache.etag ? { 'If-None-Match': cache.etag } : {}),
    },
  });
  cache.checkedAt = Date.now();
  if (res.status === 304) return cache.flags;
  if (!res.ok) throw new Error(`flags ${res.status}: ${await res.text()}`);
  const body = await res.json();
  cache = { ...cache, etag: res.headers.get('etag'), flags: body.flags };
  return cache.flags;
}

// const flags = await getFlags();
// if (flags['new-checkout']) { … }

Coderunners deployed before this variable existed pick up GROUNDFLOOR_ENVIRONMENT_ID on their next deploy.

Promote flags

Open Environments under Build, choose source and target environments, and use the feature-flags promote panel. The diff shows flag values (unlike secrets, where values stay hidden). From the CLI: gf flags compare dev prod, then gf flags promote dev prod (add --overwrite to replace values already in the target).

See Environment Promotion for the full promote flow.

Troubleshooting

ProblemLikely causeWhat to do
Flag missing after createWrong environmentConfirm the environment selector or -e; responses echo environment_id
App reads dev values in prodApp not bound to prod, or environment_id not passedCheck the app's environment in Apps; pass window.__GF_ENVIRONMENT_ID__ (the kit does)
403 from …/flags/runtime in the ShellThe app user has no roleGive them a role on the Users tab of App authentication
403 on upsert from a CoderunnerM2M tokens are read-only on flagsChange flags with gf flags set or the portal
400 on upsertInvalid or reserved keyUse lowercase letters, digits, -, _, .; start with a letter
422 on upsertvalue doesn't match value_typeFor example "five" with number; see the API
Promote skipped a keyTarget already has a value (skip_existing)Re-run with overwrite, or edit the target

On this page