Start · Build with AI
Integrate Darkship with a coding agent
This page is written for an AI coding agent (and for the developer who supervises it). Give the agent this URL and one sentence of context, and it has what it needs to add feature flags to your project: how the system works, which key goes where, tested OpenFeature providers, and the rules to follow.
How to use this page#
Paste the prompt below into your agent (Claude Code, Cursor, Copilot, Codex or similar), replacing the bracketed parts. If the agent cannot browse, give it /llms-full.txt, which contains every docs page as plain text, or the OpenAPI document at /openapi.json.
Integrate Darkship feature flags into this project. Read and follow
https://darkship.tech/docs/ai/integrate/ exactly. Use OpenFeature with the provider from
that page (there is no Darkship SDK package to install yet).
Context:
- The flag to add: [flag key, type, what it controls, safe default]
- Where it is evaluated: [server route / component / job]
- Environment variables are already set: DARKSHIP_BASE_URL and DARKSHIP_CLIENT_KEY
(or tell me where to put them).
Do not create or change flags in production without asking me.What the system is#
- Darkship is a feature flag service. A flag has a key, a type and per-environment targeting (an on/off switch, target lists, rules, a default). Evaluating a flag for a context (a user key plus attributes) returns a value, the variation that produced it and a reason.
- Flag types:
boolean,string,int,float,json,date. The type is fixed when the flag is created. Flag keys match^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$(use kebab-case, for examplenew-checkout-flow). - Hierarchy: organisation, project, environment (for example
dev,staging,production). A key belongs to exactly one environment, so the same code behaves differently per environment just by changing the key. - Evaluation order: the environment switch, then target lists, then rules top to bottom (first match wins), then the default. Percentage rollouts hash the context
key, so always send a stabletargetingKey. - Evaluation never throws. When anything is wrong (flag missing, wrong type, service unreachable) the caller gets its own default value and an error code. Always choose a default that is safe if the flag cannot be read.
Hosts#
| Purpose | URL |
|---|---|
| Evaluation (SDK data plane) | https://sdk.darkship.tech |
| Management API | https://api.darkship.tech/v1 |
| Console | https://app.darkship.tech |
Keys#
| Credential | Prefix | Use it for | Where it may live |
|---|---|---|---|
| Client SDK key | ds_cli_ | POST /v1/sdk/evaluate (remote evaluation). This is what the providers below use. | Environment variable or build config. Designed to be public; it only reveals resolved values. |
| Server SDK key | ds_srv_ | Downloading the whole ruleset for local evaluation (needs a native SDK, not available yet). | Secret store only. Never in a browser, mobile app or repository. |
| API token | ds_pat_ | Management API: create flags, toggle them, manage targeting. | Secret store only. Scoped; prefer the narrowest scopes. |
The integration path today#
Do not run npm install @darkship/sdk or pip install darkship and do not look for them on a registry. Until the SDKs are published, integrate through OpenFeature (the vendor-neutral standard) with the small provider on this page, which calls the remote evaluation endpoint. Do not re-implement rule evaluation yourself; that logic belongs to the SDKs and to the service.
- Your application code only talks to the standard OpenFeature client (
getBooleanValueand friends). The provider is the only Darkship-specific file. - When the native SDKs ship, only the provider registration changes; the flag calls in your code stay exactly the same.
- Limitation: remote evaluation only serves flags that are enabled for client-side use (the flag's client-side setting in the console,
client_side: truein the API). Enabling it means anyone holding the client key can read that flag's resolved value for any context they supply, so do not use it for secrets. A flag that is not client-side returnsFLAG_NOT_FOUND. - Each remote evaluation call counts as a request against the plan quota. The providers cache the flags for a context for 10 seconds, which is usually enough; raise
cacheTtlMsfor hot paths.
Steps for the agent#
- Inspect the project. Find the language, framework, package manager, where configuration lives, how tests are written, and whether OpenFeature is already used (search for
openfeature). Reuse existing conventions. If OpenFeature is already present, add only the provider. - Install the OpenFeature SDK for the runtime:
@openfeature/server-sdk(Node),@openfeature/web-sdk(browser) oropenfeature-sdk(Python). Other languages: use that language's OpenFeature SDK and implement a provider from the evaluation contract. - Add configuration. Two variables, never hard-coded:
DARKSHIP_BASE_URL=https://sdk.darkship.techandDARKSHIP_CLIENT_KEY=ds_cli_...(one key per environment). Add them to the project's example env file with an empty key, never a real value. Do not commit keys. - Add the provider file from the next section, adapted to the project's style (imports, lint rules, logging). Keep it in one module, for example
src/lib/darkship-provider. - Register it once at startup and wait for it to be ready, then use
OpenFeature.getClient()anywhere. Do not create a provider per request. - Evaluate with a safe default and a stable context. Pass
targetingKey(the user, organisation or anonymous-visitor id) and any attributes your rules use (plan,country,orgId...). Keep the old behaviour as the default so that nothing changes if the flag is missing. - Wrap the feature in a single, small conditional. One flag controls one decision; avoid scattering the same flag over many files, and avoid nested flags.
- Test without the network. Use OpenFeature's in-memory provider in unit tests (
InMemoryProviderin the JavaScript SDKs,InMemoryProviderin Python) so tests never call Darkship. Cover both flag states and the default path. - Verify with the checklist at the end of this page and tell the human which flags must exist, with which type, default and client-side setting.
Providers#
These providers were tested against the production evaluation service. They resolve boolean, string, number and object flags, map reasons and errors to the OpenFeature standard, apply a request timeout, never cache a failed request, and bound the cache. date flags arrive as ISO-8601 strings (use string evaluation); int and float flags use number evaluation; json flags use object evaluation.
Node.js (server)#
$ npm install @openfeature/server-sdkimport {
ErrorCode,
StandardResolutionReasons,
type EvaluationContext,
type JsonValue,
type Provider,
type ResolutionDetails,
} from '@openfeature/server-sdk';
type Wire = {
value: JsonValue;
variation_key: string | null;
reason: 'OFF' | 'TARGET_MATCH' | 'RULE_MATCH' | 'FALLTHROUGH' | 'ERROR';
error_code?: string;
bucket?: number;
flag_type?: 'boolean' | 'string' | 'int' | 'float' | 'json' | 'date';
};
export interface DarkshipProviderOptions {
/** Evaluation host, e.g. https://sdk.darkship.tech (no trailing path). */
baseUrl: string;
/** Client key (ds_cli_...). Remote evaluation only accepts client keys. */
clientKey: string;
timeoutMs?: number;
/** How long the flags resolved for one context are reused. */
cacheTtlMs?: number;
}
const MAX_CACHED_CONTEXTS = 1000;
/** OpenFeature context -> wire context: `targetingKey` becomes `key`, Dates become ISO strings. */
function toWireContext(ctx: EvaluationContext): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(ctx)) {
if (k === 'targetingKey' || v === undefined) continue;
out[k] = v instanceof Date ? v.toISOString() : v;
}
if (typeof ctx.targetingKey === 'string' && ctx.targetingKey !== '') out.key = ctx.targetingKey;
return out;
}
function mapReason(w: Wire): string {
switch (w.reason) {
case 'OFF': return StandardResolutionReasons.STATIC;
case 'TARGET_MATCH':
case 'RULE_MATCH': return w.bucket === undefined ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.SPLIT;
case 'FALLTHROUGH': return w.bucket === undefined ? StandardResolutionReasons.DEFAULT : StandardResolutionReasons.SPLIT;
default: return StandardResolutionReasons.ERROR;
}
}
export class DarkshipProvider implements Provider {
readonly metadata = { name: 'darkship-remote' } as const;
readonly runsOn = 'server' as const;
private readonly cache = new Map<string, { at: number; flags: Promise<Record<string, Wire>> }>();
constructor(private readonly opts: DarkshipProviderOptions) {}
resolveBooleanEvaluation(key: string, def: boolean, ctx: EvaluationContext) {
return this.resolve(key, def, ctx, (w) => typeof w.value === 'boolean');
}
resolveStringEvaluation(key: string, def: string, ctx: EvaluationContext) {
return this.resolve(key, def, ctx, (w) => typeof w.value === 'string');
}
resolveNumberEvaluation(key: string, def: number, ctx: EvaluationContext) {
return this.resolve(key, def, ctx, (w) => typeof w.value === 'number');
}
resolveObjectEvaluation<T extends JsonValue>(key: string, def: T, ctx: EvaluationContext) {
return this.resolve(key, def, ctx, (w) => typeof w.value === 'object' && w.value !== null);
}
private async resolve<T extends JsonValue>(
flagKey: string,
def: T,
ctx: EvaluationContext,
matchesType: (w: Wire) => boolean,
): Promise<ResolutionDetails<T>> {
const fail = (errorCode: ErrorCode, errorMessage: string): ResolutionDetails<T> => ({
value: def,
reason: StandardResolutionReasons.ERROR,
errorCode,
errorMessage,
});
let flags: Record<string, Wire>;
try {
flags = await this.flagsFor(ctx);
} catch (err) {
return fail(ErrorCode.GENERAL, `darkship: evaluation request failed (${(err as Error).message})`);
}
const w = Object.hasOwn(flags, flagKey) ? flags[flagKey] : undefined;
if (w === undefined || w.error_code === 'FLAG_NOT_FOUND') {
return fail(ErrorCode.FLAG_NOT_FOUND, `darkship: flag "${flagKey}" not found, or not enabled for client-side use`);
}
if (w.reason === 'ERROR') return fail(w.error_code === 'INVALID_CONTEXT' ? ErrorCode.INVALID_CONTEXT : ErrorCode.GENERAL, `darkship: ${w.error_code ?? 'error'}`);
if (!matchesType(w)) return fail(ErrorCode.TYPE_MISMATCH, `darkship: flag "${flagKey}" is a ${w.flag_type ?? 'different'} flag`);
return {
value: w.value as T,
variant: w.variation_key ?? undefined,
reason: mapReason(w),
flagMetadata: { ...(w.flag_type ? { flagType: w.flag_type } : {}), ...(w.bucket !== undefined ? { bucket: w.bucket } : {}) },
};
}
/** All client-visible flags for one context, cached briefly and shared by concurrent callers. */
private flagsFor(ctx: EvaluationContext): Promise<Record<string, Wire>> {
const wireCtx = toWireContext(ctx);
const cacheKey = JSON.stringify(Object.entries(wireCtx).sort(([a], [b]) => a.localeCompare(b)));
const hit = this.cache.get(cacheKey);
if (hit && Date.now() - hit.at < (this.opts.cacheTtlMs ?? 10_000)) return hit.flags;
const flags = this.fetchFlags(wireCtx);
this.cache.set(cacheKey, { at: Date.now(), flags });
flags.catch(() => this.cache.delete(cacheKey)); // never cache a failure
if (this.cache.size > MAX_CACHED_CONTEXTS) this.cache.delete(this.cache.keys().next().value as string);
return flags;
}
private async fetchFlags(context: Record<string, unknown>): Promise<Record<string, Wire>> {
const res = await fetch(`${this.opts.baseUrl.replace(/\/+$/, '')}/v1/sdk/evaluate`, {
method: 'POST',
headers: { authorization: `Bearer ${this.opts.clientKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ context }),
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 2000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return ((await res.json()) as { flags: Record<string, Wire> }).flags;
}
}import { OpenFeature } from '@openfeature/server-sdk';
import { DarkshipProvider } from './lib/darkship-provider';
await OpenFeature.setProviderAndWait(
new DarkshipProvider({
baseUrl: process.env.DARKSHIP_BASE_URL!,
clientKey: process.env.DARKSHIP_CLIENT_KEY!,
}),
);
const flags = OpenFeature.getClient();
// Per request: stable targetingKey + the attributes your rules use.
const useNewCheckout = await flags.getBooleanValue(
'new-checkout-flow',
false, // safe default: the old behaviour
{ targetingKey: user.id, plan: user.plan, orgId: org.id },
);Browser#
Browser providers resolve synchronously, so the flags for the current context are loaded in advance: when the provider starts and whenever you call OpenFeature.setContext (for example after sign-in). Use only a ds_cli_ key here; the code must never contain a ds_srv_ or ds_pat_ value.
$ npm install @openfeature/web-sdkimport {
ErrorCode,
StandardResolutionReasons,
type EvaluationContext,
type JsonValue,
type Provider,
type ResolutionDetails,
} from '@openfeature/web-sdk';
type Wire = {
value: JsonValue;
variation_key: string | null;
reason: 'OFF' | 'TARGET_MATCH' | 'RULE_MATCH' | 'FALLTHROUGH' | 'ERROR';
error_code?: string;
bucket?: number;
flag_type?: string;
};
export interface DarkshipWebProviderOptions {
baseUrl: string;
/** Public client key (ds_cli_...). Never a server key (ds_srv_...). */
clientKey: string;
timeoutMs?: number;
}
function toWireContext(ctx: EvaluationContext): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(ctx)) {
if (k === 'targetingKey' || v === undefined) continue;
out[k] = v instanceof Date ? v.toISOString() : v;
}
if (typeof ctx.targetingKey === 'string' && ctx.targetingKey !== '') out.key = ctx.targetingKey;
return out;
}
/**
* Web providers resolve synchronously, so the flags for the current context are fetched in advance:
* once in initialize() and again whenever the context changes (OpenFeature.setContext).
*/
export class DarkshipWebProvider implements Provider {
readonly metadata = { name: 'darkship-web' } as const;
readonly runsOn = 'client' as const;
private flags: Record<string, Wire> = {};
constructor(private readonly opts: DarkshipWebProviderOptions) {}
async initialize(context?: EvaluationContext): Promise<void> {
await this.load(context ?? {});
}
async onContextChange(_old: EvaluationContext, next: EvaluationContext): Promise<void> {
await this.load(next);
}
resolveBooleanEvaluation(key: string, def: boolean) {
return this.resolve(key, def, (w) => typeof w.value === 'boolean');
}
resolveStringEvaluation(key: string, def: string) {
return this.resolve(key, def, (w) => typeof w.value === 'string');
}
resolveNumberEvaluation(key: string, def: number) {
return this.resolve(key, def, (w) => typeof w.value === 'number');
}
resolveObjectEvaluation<T extends JsonValue>(key: string, def: T) {
return this.resolve(key, def, (w) => typeof w.value === 'object' && w.value !== null);
}
private resolve<T extends JsonValue>(flagKey: string, def: T, matchesType: (w: Wire) => boolean): ResolutionDetails<T> {
const fail = (errorCode: ErrorCode, errorMessage: string): ResolutionDetails<T> => ({
value: def,
reason: StandardResolutionReasons.ERROR,
errorCode,
errorMessage,
});
const w = Object.hasOwn(this.flags, flagKey) ? this.flags[flagKey] : undefined;
if (w === undefined || w.error_code === 'FLAG_NOT_FOUND') {
return fail(ErrorCode.FLAG_NOT_FOUND, `darkship: flag "${flagKey}" not found, or not enabled for client-side use`);
}
if (w.reason === 'ERROR') return fail(ErrorCode.GENERAL, `darkship: ${w.error_code ?? 'error'}`);
if (!matchesType(w)) return fail(ErrorCode.TYPE_MISMATCH, `darkship: flag "${flagKey}" is a ${w.flag_type ?? 'different'} flag`);
const reason =
w.reason === 'OFF' ? StandardResolutionReasons.STATIC
: w.bucket !== undefined ? StandardResolutionReasons.SPLIT
: w.reason === 'FALLTHROUGH' ? StandardResolutionReasons.DEFAULT
: StandardResolutionReasons.TARGETING_MATCH;
return { value: w.value as T, variant: w.variation_key ?? undefined, reason };
}
private async load(context: EvaluationContext): Promise<void> {
const res = await fetch(`${this.opts.baseUrl.replace(/\/+$/, '')}/v1/sdk/evaluate`, {
method: 'POST',
headers: { authorization: `Bearer ${this.opts.clientKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ context: toWireContext(context) }),
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 3000),
});
// Throwing here puts the provider in the ERROR state; every resolution then returns the caller's default.
if (!res.ok) throw new Error(`darkship: evaluation request failed (HTTP ${res.status})`);
this.flags = ((await res.json()) as { flags: Record<string, Wire> }).flags;
}
}import { OpenFeature } from '@openfeature/web-sdk';
import { DarkshipWebProvider } from './lib/darkship-web-provider';
await OpenFeature.setContext({ targetingKey: user.id, plan: user.plan });
await OpenFeature.setProviderAndWait(
new DarkshipWebProvider({
baseUrl: import.meta.env.VITE_DARKSHIP_BASE_URL, // https://sdk.darkship.tech
clientKey: import.meta.env.VITE_DARKSHIP_CLIENT_KEY, // ds_cli_...
}),
);
const flags = OpenFeature.getClient();
const showBanner = flags.getBooleanValue('promo-banner', false);
// After the user changes (sign-in, plan change), reload the flags:
await OpenFeature.setContext({ targetingKey: newUser.id, plan: newUser.plan });For React, add @openfeature/react-sdk and wrap the app in OpenFeatureProvider; it works with this provider unchanged.
Python#
Standard library only (no HTTP dependency). Tested with openfeature-sdk 0.10; check the provider interface if you use a very different version.
$ pip install openfeature-sdkfrom __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from typing import Any, Callable, Optional, Sequence, Union
from openfeature.evaluation_context import EvaluationContext
from openfeature.exception import FlagNotFoundError, GeneralError, InvalidContextError, TypeMismatchError
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
from openfeature.hook import Hook
from openfeature.provider import AbstractProvider, Metadata
JsonValue = Union[bool, int, float, str, list, dict, None]
class DarkshipProvider(AbstractProvider):
"""OpenFeature provider over the remote evaluation endpoint (client key, ds_cli_...)."""
def __init__(self, base_url: str, client_key: str, timeout: float = 2.0, cache_ttl: float = 10.0) -> None:
super().__init__()
self._url = base_url.rstrip("/") + "/v1/sdk/evaluate"
self._key = client_key
self._timeout = timeout
self._ttl = cache_ttl
self._cache: dict[str, tuple[float, dict[str, Any]]] = {}
def get_metadata(self) -> Metadata:
return Metadata(name="darkship-remote")
def get_provider_hooks(self) -> list[Hook]:
return []
def resolve_boolean_details(self, flag_key: str, default_value: bool, evaluation_context: Optional[EvaluationContext] = None):
return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, bool))
def resolve_string_details(self, flag_key: str, default_value: str, evaluation_context: Optional[EvaluationContext] = None):
return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, str))
def resolve_integer_details(self, flag_key: str, default_value: int, evaluation_context: Optional[EvaluationContext] = None):
return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, int) and not isinstance(v, bool))
def resolve_float_details(self, flag_key: str, default_value: float, evaluation_context: Optional[EvaluationContext] = None):
return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, (int, float)) and not isinstance(v, bool))
def resolve_object_details(self, flag_key: str, default_value: Union[Sequence[JsonValue], dict], evaluation_context: Optional[EvaluationContext] = None):
return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, (list, dict)))
# The SDK turns a raised OpenFeature exception into the caller's default value plus an error code.
def _resolve(self, flag_key: str, ctx: Optional[EvaluationContext], matches: Callable[[Any], bool]) -> FlagResolutionDetails:
flags = self._flags_for(ctx)
flag = flags.get(flag_key)
if flag is None or flag.get("error_code") == "FLAG_NOT_FOUND":
raise FlagNotFoundError(f'darkship: flag "{flag_key}" not found, or not enabled for client-side use')
if flag.get("reason") == "ERROR":
if flag.get("error_code") == "INVALID_CONTEXT":
raise InvalidContextError("darkship: INVALID_CONTEXT")
raise GeneralError(f"darkship: {flag.get('error_code', 'error')}")
if not matches(flag["value"]):
raise TypeMismatchError(f'darkship: flag "{flag_key}" is a {flag.get("flag_type", "different")} flag')
reason = {
"OFF": Reason.STATIC,
"TARGET_MATCH": Reason.TARGETING_MATCH,
"RULE_MATCH": Reason.TARGETING_MATCH,
"FALLTHROUGH": Reason.DEFAULT,
}.get(flag["reason"], Reason.UNKNOWN)
if flag.get("bucket") is not None:
reason = Reason.SPLIT
return FlagResolutionDetails(value=flag["value"], variant=flag.get("variation_key"), reason=reason)
def _flags_for(self, ctx: Optional[EvaluationContext]) -> dict[str, Any]:
wire: dict[str, Any] = dict(ctx.attributes) if ctx else {}
if ctx and ctx.targeting_key:
wire["key"] = ctx.targeting_key
cache_key = json.dumps(wire, sort_keys=True, default=str)
hit = self._cache.get(cache_key)
if hit and time.monotonic() - hit[0] < self._ttl:
return hit[1]
request = urllib.request.Request(
self._url,
data=json.dumps({"context": wire}, default=str).encode(),
headers={"authorization": f"Bearer {self._key}", "content-type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self._timeout) as response:
flags = json.load(response)["flags"]
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
raise GeneralError(f"darkship: evaluation request failed ({exc})") from exc
if len(self._cache) >= 1000:
self._cache.pop(next(iter(self._cache)))
self._cache[cache_key] = (time.monotonic(), flags)
return flagsimport os
from openfeature import api
from openfeature.evaluation_context import EvaluationContext
from darkship_provider import DarkshipProvider
api.set_provider(DarkshipProvider(os.environ["DARKSHIP_BASE_URL"], os.environ["DARKSHIP_CLIENT_KEY"]))
flags = api.get_client()
use_new_checkout = flags.get_boolean_value(
"new-checkout-flow",
False, # safe default: the old behaviour
EvaluationContext(targeting_key=user.id, attributes={"plan": user.plan, "org_id": org.id}),
)The evaluation contract#
For any other language, implement a provider from this contract. It is the same endpoint the providers above call (full schema in the API explorer).
POST https://sdk.darkship.tech/v1/sdk/evaluate
Authorization: Bearer ds_cli_...
Content-Type: application/json
{ "context": { "key": "user-123", "plan": "pro", "country": "BR" } }{
"version": "snapshot-version",
"flags": {
"new-checkout-flow": { "value": true, "variation_key": "on", "reason": "FALLTHROUGH", "flag_type": "boolean" },
"max-seats": { "value": 25, "variation_key": "pro", "reason": "RULE_MATCH", "flag_type": "int" },
"retired-flag": { "value": null, "variation_key": null, "reason": "ERROR", "error_code": "FLAG_NOT_FOUND" }
}
}contextis required and must be a JSON object. Put the stable user or organisation id inkey; every other attribute is free-form (strings, numbers, booleans, ISO-8601 dates, arrays of strings). Do not send secrets or more personal data than your rules need.- To evaluate only some flags add
"flag_keys": ["a", "b"](up to 1000).POST /v1/sdk/evaluate/{flagKey}evaluates one flag and returns that entry plusflag_keyandversion. - Responses are never cacheable on the HTTP level (
no-store); cache in your client. HonourRetry-Afteron429.
Wire reason | OpenFeature reason |
|---|---|
OFF | STATIC |
TARGET_MATCH, RULE_MATCH | TARGETING_MATCH, or SPLIT when bucket is present |
FALLTHROUGH | DEFAULT, or SPLIT when bucket is present |
ERROR (with error_code) | ERROR |
| Situation | OpenFeature error code |
|---|---|
Flag absent, archived, not client-side, or from another environment (FLAG_NOT_FOUND) | FLAG_NOT_FOUND |
| Value type differs from the requested type | TYPE_MISMATCH |
Context rejected (INVALID_CONTEXT) | INVALID_CONTEXT |
| Timeout, network failure, HTTP 401/403/5xx | GENERAL |
Managing flags from the agent (optional)#
Flags can be created in the console or through the management API with an ds_pat_ API token (created under the console's API tokens; give it only the flag scopes it needs). Look up the exact schemas in /openapi.json. The calls an agent usually needs:
| Goal | Call |
|---|---|
| List projects and environments | GET /v1/projects, GET /v1/projects/{projectId}/environments |
| List and read flags | GET /v1/projects/{projectId}/flags, GET /v1/projects/{projectId}/flags/{flagKey} |
| Create a flag | POST /v1/projects/{projectId}/flags with { "key", "name", "type", "client_side": true } |
| Switch a flag on or off in one environment | POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/toggle with { "enabled": true } |
| Read or change targeting | GET, PATCH or PUT /v1/projects/{projectId}/flags/{flagKey}/environments/{envId} |
| Preview an evaluation | POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/evaluate-preview |
| Create a client SDK key | POST /v1/projects/{projectId}/environments/{envId}/sdk-keys with { "role": "client", "name": "..." } |
$ curl -X POST https://api.darkship.tech/v1/projects/$PROJECT_ID/flags \
-H "Authorization: Bearer $DARKSHIP_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "key": "new-checkout-flow", "name": "New checkout flow", "type": "boolean", "client_side": true }'Mutations accept an Idempotency-Key header and flags return an ETag to send back in If-Match when updating. See the REST API reference for conventions, pagination and errors.
Rules for the agent#
Do#
- Use a safe default at every call site: the pre-flag behaviour, so a missing flag or an outage changes nothing.
- Pass a stable
targetingKey. For anonymous visitors, generate and persist an anonymous id; do not use a value that changes on every request, or percentage rollouts will flicker. - Register the provider once, and reuse the client. Keep flag evaluation off tight loops; evaluate once per request or render and pass the result down.
- Name flags by capability in kebab-case (
new-checkout-flow,export-csv), match the type to the use (booleanfor on/off,stringfor variants,intfor limits), and remove the flag and the dead branch once a rollout is complete. - Mock the provider in tests and cover both branches.
- Ask the human before creating, changing or toggling flags in a production environment.
Do not#
- Do not put a
ds_srv_ords_pat_value in browser code, mobile apps, repositories, logs or error messages. Do not print any key. - Do not put personal data, secrets or card numbers in flag keys, names, descriptions or rule values, and do not send more context attributes than the rules use.
- Do not re-implement targeting or percentage bucketing, and do not call the evaluation endpoint on every render without a cache.
- Do not use a flag for authorisation. Flags decide what to show or enable; permissions are enforced on your server.
- Do not catch and hide evaluation errors in a way that loses the default; the OpenFeature client already returns the default.
Verify#
- The flag exists in the target environment, has the intended type, and has client-side enabled.
- The key is a
ds_cli_key for the same environment, and the variables are set. - Check the raw response from a shell (replace the key; the value of
$KEYis a client key):
$ curl -s https://sdk.darkship.tech/v1/sdk/evaluate \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "context": { "key": "user-1" } }'- Evaluate once through OpenFeature and print the details (
getBooleanDetails): checkvalue,variant,reasonand, if something is wrong,errorCode. - Run the project's tests and linter; make sure no key appears in the diff.
| Symptom | Likely cause |
|---|---|
| HTTP 401 or 403 | Key missing, revoked, from another environment, or the wrong type (a ds_srv_ or ds_pat_ key on the evaluation endpoint). |
FLAG_NOT_FOUND | Typo in the key, flag archived, flag is not enabled for client-side use, or the key belongs to a different environment. |
TYPE_MISMATCH | The call type differs from the flag type (for example a string call on a boolean flag). |
| Always the default | Service unreachable or timing out (check DARKSHIP_BASE_URL and the network), or the environment has no published ruleset yet. |
| HTTP 429 | Rate limited. Raise the cache TTL and honour Retry-After. |
| Changes take a few seconds to appear | Expected: the provider caches for 10 seconds per context and does not stream updates. |
Related#
- Evaluation and Targeting and rollouts explain the model in depth.
- OpenFeature provider (native SDK) describes the provider that will ship with the SDK.
- Plan gating is a worked example of rules on a
planattribute.
Thanks. This helps us prioritise the docs.