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
| Action | Who |
|---|---|
| List / get / compare flags | Workspace read (owner, admin, writer, member) or an M2M token |
| Create / update / promote | Workspace write (owner, admin, writer). M2M tokens are read-only on flags. |
| Delete | Workspace 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 devAgents 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) | |
|---|---|---|
| Token | Shell end-user JWT: await window.__GF_GET_ACCESS_TOKEN__() | M2M token (M2M tokens, gf m2m token) |
| Permission | Any 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. |
| Environment | window.__GF_ENVIRONMENT_ID__, the environment the app is bound to, published by the Shell before your app mounts | GROUNDFLOOR_ENVIRONMENT_ID, injected on deploy (the Coderunner's bound environment) |
| Helper | Starter 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
| Problem | Likely cause | What to do |
|---|---|---|
| Flag missing after create | Wrong environment | Confirm the environment selector or -e; responses echo environment_id |
| App reads dev values in prod | App not bound to prod, or environment_id not passed | Check the app's environment in Apps; pass window.__GF_ENVIRONMENT_ID__ (the kit does) |
403 from …/flags/runtime in the Shell | The app user has no role | Give them a role on the Users tab of App authentication |
403 on upsert from a Coderunner | M2M tokens are read-only on flags | Change flags with gf flags set or the portal |
400 on upsert | Invalid or reserved key | Use lowercase letters, digits, -, _, .; start with a letter |
422 on upsert | value doesn't match value_type | For example "five" with number; see the API |
| Promote skipped a key | Target already has a value (skip_existing) | Re-run with overwrite, or edit the target |
Related
- Feature Flags API — full reference
- Shell: Feature flags — Shell host globals and the kit helper
- M2M tokens — Coderunner credentials
- Environment Promotion
- Secrets — separate config channel (values never shown in diffs)