from @unified-product-graph/sdk
The high-level programmatic client for reading and writing .upg product knowledge graphs. Constructs over a single file, namespaces node and edge operations under .nodes / .edges, and persists to disk after each successful mutation.
new UPGClient(options: UPGClientOptions)
Builds a client bound to one .upg file. The file is not read until the first operation runs (lazy by default).
import { UPGClient } from '@unified-product-graph/sdk'
const upg = new UPGClient({ file: './product.upg' })The constructor does not create the file. If ./product.upgdoesn’t exist, the first read or write throws ENOENT. Scaffold one first with npx @unified-product-graph/cli init --file ./product.upg --title “My Product” --yes. (A single-call UPGClient.init() factory is planned.)
load(): Promise<void>
Explicitly reads and parses the file. Called automatically by every operation if the client hasn’t loaded yet. Call it directly only if you want to surface ENOENT or parse errors at startup.
flush(): Promise<void>
Persists pending in-memory state to disk. Every mutation flushes automatically, so this is rarely needed.
close(): Promise<void>
Flushes and releases the file handle. Call before your process exits if you’ve done long-lived work.
create(args: CreateNodeArgs): Promise<{ node, edge? }>Creates a typed node. If parent_id is provided, an auto-parent edge is created and returned alongside the node.
const { node } = await upg.nodes.create({
type: 'feature',
title: 'Dark mode',
})
console.log(node.id) // 'n_Kg19aubWwbwm7V5t'Return shape note: create() returns { node, edge? }, not the node directly. Destructure.
list(options?: NodeListOptions): Promise<{ nodes, total }>Lists nodes. Optional filters: type, parent_id, status, tags.
const { nodes, total } = await upg.nodes.list({ type: 'feature' })
console.log(total, nodes.length)Return shape note: list() returns { nodes, total }, not a bare array. Use result.nodes for the array, result.total for the unfiltered count.
createMany(args: BatchCreateArgs): Promise<BatchCreateResult>
Creates many nodes atomically in one disk write. Supports parent_ref chaining ("$0", "$1" reference earlier nodes in the same batch) and an optional explicit edges[] array.
const result = await upg.nodes.createMany({
nodes: [
{ type: 'persona', title: 'Returning shopper' },
{ type: 'job', title: 'Pay fast', parent_ref: '$0' },
],
})
if (!result.ok) console.error(result.error)Result is a union, not a throw. On validation failure it returns { ok: false, error } and writes NOTHING (all-or-nothing). Check result.ok before reading the created nodes.
get(id: string): Promise<UPGBaseNode | undefined> get(id: string, opts: { withEdges: true }): Promise<GetNodeResult | undefined>Returns the node or undefined (does not throw on missing). Pass { withEdges: true } to get the full { node, edges_out, edges_in } shape instead of the bare node.
const node = await upg.nodes.get(id) // → UPGBaseNode | undefined
const full = await upg.nodes.get(id, { withEdges: true }) // → { node, edges_out, edges_in }inspect(id: string): Promise<GetNodeResult | undefined>
Deep-dive a node with its connections: { node, edges_out, edges_in }. Equivalent to get(id, { withEdges: true }).
update(id: string, patch: Partial<UPGBaseNode> & { unset_properties?: string[]; strict?: boolean }): Promise<UPGBaseNode>Merges patch into the existing node and persists. An invalid status (outside the type’s lifecycle) throws WriteValidationError; unknown properties warn (or throw under { strict: true }).
To DELETE property keys, pass unset_properties: […]. Writing { properties: { key: null } } only stores a literal null; it cannot remove the key. unset_properties runs after the properties merge.
delete(id: string): Promise<void>
Removes the node. Edges with this node as source or target are removed as well to keep the graph well-formed.
connect(sourceId: string, targetId: string, opts?: Partial<CreateEdgeArgs>): Promise<{ edge, warning? } | { error, no_canonical_edge_for? }>Connects two nodes with a directed edge source → target. When opts.type is omitted, the edge type is inferred from (source.type, target.type), and direction matters: solution → feature resolves to solution_becomes_feature; the reverse has no canonical edge.
connect() returns a union; it does NOT throw on an invalid pair. Most type pairs have no canonical edge. Handle the result by inspecting .error, not with try/catch:
· success → { edge, warning? }
· failure → { error, no_canonical_edge_for? }
Passing an explicit typedoes not override direction: the type’s declared source/target must still match the nodes (a wrong-way link is inexpressible, so reorient the call).
const result = await upg.edges.connect(persona.id, job.id)
if ('error' in result) {
console.error(result.error) // no canonical edge for this pair/direction
} else {
console.log(result.edge.type) // → 'persona_pursues_job'
}resolve(sourceType: string, targetType: string): EdgeResolution | null
Resolves the canonical edge type for a directed (sourceType → targetType) pair WITHOUT touching the graph. Returns { type } or null when the pair has no canonical edge. Use it (or schema.edgeFor) to discover the right edge + direction before calling connect.
upg.edges.resolve('feature', 'solution') // → null (no edge this way)
upg.edges.resolve('solution', 'feature') // → { type: 'solution_becomes_feature' }list(options?: EdgeListOptions): Promise<UPGEdge[]>
Lists edges, filterable by source, target, or type. Returns an array directly (unlike nodes.list).
delete(id: string): Promise<void>
Removes a single edge.
Pure schema introspection: the catalog questions an author answers to build a VALID graph, from the client you’re already holding instead of a separate @unified-product-graph/core import. No graph state is read.
validChildren(type: string): readonly string[]
Entity types that may be hierarchy children of type.
edgesFrom(sourceType: string): { type, target_type }[]Every canonical edge LEAVING sourceType (each source → * edge), as { type, target_type } pairs.
edgeFor(a: string, b: string): UPGEdgeType | null
The canonical edge for a directed (a → b) pair, or null. Direction matters. The alias of edges.resolve(a, b)?.type, surfaced on schema for discoverability.
upg.schema.validChildren('feature') // → ['task', 'acceptance_criterion', ...]
upg.schema.edgesFrom('persona') // → [{ type: 'persona_pursues_job', target_type: 'job' }, ...]
upg.schema.edgeFor('solution', 'feature') // → 'solution_becomes_feature'Read and update the product metadata on the graph (stage, title, and any extra fields).
get(): Promise<{ id?, title?, stage? } & Record<string, unknown>>Returns the current product object.
update(patch: { stage?, title? } & Record<string, unknown>): Promise<Record<string, unknown>>Merges patchover the existing product object and persists. This is the supported way to set a product’s stage through the client (it used to require editing the .upg JSON by hand).
await upg.product.update({ stage: 'build', title: 'Checkout Redesign' })health(): Promise<HealthResult>
Computes a structural health score (0–100) plus the underlying graph digest. Useful as a CI gate to keep a long-running graph from rotting.
const { score, digest } = await upg.health()
// → { score: 82, digest: { nodes: 42, edges: 73, ... } }search(query: string, options?: SearchOptions): Promise<SearchResult[]>
Fuzzy title + description match across all nodes. Cheap.
verify(): Promise<VerifyResult>
Checks graph integrity and returns { ok, tampered, quarantined, orphanedEdges }. Use before persisting any batch write from an adapter or migration.
The clean-check is (await upg.verify()).ok, not if (await upg.verify()). The report object is always truthy, so a bare truthiness check always passes. ok: true means no integrity issues; the other fields explain WHY when it is false.
const report = await upg.verify()
if (!report.ok) {
console.error('integrity issues:', report.quarantined, report.orphanedEdges)
}diff(ref: string): Promise<never>
Not yet implemented. Currently throws. Planned alongside the broader ergonomics work.
interface UPGClientOptions {
file: string // path to .upg
lazy?: boolean // defer load() until first op (default true)
}
interface NodeListOptions {
type?: string
parent_id?: string
status?: string
tags?: string[]
}
interface EdgeListOptions {
source?: string
target?: string
type?: string
}
interface HealthResult {
score: number // 0–100
digest: GraphDigest
}
interface SearchOptions {
limit?: number // default 20
type?: string // restrict to one entity type
}
interface VerifyResult { // returned by upg.verify()
ok: boolean // the clean-check
tampered: boolean
quarantined: QuarantinedEntity[]
orphanedEdges: number
}Beyond CRUD, the SDK exports a set of pure functions that reason over a loaded UPGFileStore: a backlog from coverage gaps, a typed path walk, framework-driven ranking, and reflection prompts. Import them from the package root and pass the store from a client via the advanced primitives.
import {
executePlan, executeTrace, executePrioritise, executeReflect,
applyFramework, applyFrameworkEnvelope, scoreEntity,
} from '@unified-product-graph/sdk'executePlan(store: UPGFileStore, options?: { region?: string; exhaustive?: boolean } | string): PlanResultComputes a missing-entity backlog from the graph’s coverage against the canonical creation sequences. Scope precedence: region → exhaustive(the full 320-type universe; opt-in) → default (the product’s ACTIVE regions, i.e. every region with at least one entity). The legacy executePlan(store, regionId) positional form still works.
executePlan(store) // active regions (default)
executePlan(store, { region: 'discovery' }) // one region/domain
executePlan(store, { exhaustive: true }) // whole 320-type universeexecuteTrace(store: UPGFileStore, anchor: string, path: string[], edgesOverride?: (string | null)[]): TraceResult
Walks a typed path from anchor. pathis the list of entity types to visit AFTER the anchor; it does NOT include the anchor’s own type. Each element is one hop; the walker resolves the canonical edge for previousType → path[i] unless edgesOverride[i] supplies one. The anchor is depth 0, path[0] is depth 1, and so on.
// From a persona, walk persona → job → need:
executeTrace(store, personaId, ['job', 'need'])
// trail: [persona(d0), job(d1), need(d2)]
// DON'T include the anchor's own type: ['persona', 'job', 'need']
// asks for a persona → persona self-hop and halts at depth 1.When a hop has no canonical edge and no override, the trace halts and returns a partial trail plus error and halted_at_depth.
executePrioritise(framework: UPGFramework, candidateIds: string[], store: UPGFileStore, opts?: { exerciseId?: string }): PrioritiseExecutionResult | PrioritiseFallbackResult | PrioritiseTypeMismatchResultRanks candidates by a framework’s first numeric computed_properties expression (e.g. RICE’s (reach * impact * confidence) / effort). Pass the framework OBJECT (from UPG_FRAMEWORKS_BY_ID[id]in core), not just an id. Inputs are sourced from each node’s properties. Pass { exerciseId }(0.8.4) to source inputs from a framework exercise’s includes edges instead; that also lets you score any entity type, since the target-type guard is skipped for an exercise.
Returns a discriminated union; check .kind. 'execution' (ranked rows), 'type_mismatch' (candidates are the wrong type for the framework, e.g. RICE scores feature, not opportunity), or 'fallback' (the framework has no computed expression).
import { UPG_FRAMEWORKS_BY_ID } from '@unified-product-graph/core'
import { frameworkTargetTypes, executePrioritise } from '@unified-product-graph/sdk'
const rice = UPG_FRAMEWORKS_BY_ID['rice-scoring']
frameworkTargetTypes(rice) // → ['feature'] — pick the right candidates up front
const result = executePrioritise(rice, featureIds, store)
if (result.kind === 'execution') console.table(result.ranked)
else if (result.kind === 'type_mismatch') console.warn(result.hint)frameworkTargetTypes(framework: UPGFramework): string[]
The entity types a framework scores (e.g. ['feature'] for RICE). Use it to select the right candidates before executePrioritise, so you never hit a type_mismatch. Returns [] when the framework declares no target (the scoring guard is then skipped).
applyFramework(store: UPGFileStore, args: { framework_id: string; title?: string; entity_ids?: string[]; status?: 'draft' | 'active' | 'archived' }): { exercise, edges, warnings }scoreEntity(store: UPGFileStore, args: { exercise_id: string; entity_id: string; values: Record<string, unknown>; replace?: boolean }): { edge, warnings } | { error }A framework exercise is one run of a framework over a set of entities (0.8.4). applyFramework creates a framework_exercise node and an includes edge to each entity it scores; scoreEntityrecords that framework’s result for one entity on its edge (a MoSCoW bucket, a RICE score, a canvas slot), auto-including the entity if it is not yet in scope. The value lives on the edge, not the node, so the same entity can sit in many exercises and any entity type can be scored. Feed the exercise id to executePrioritise to rank straight from those edges.
const { exercise } = applyFramework(store, {
framework_id: 'moscow',
title: 'Q3 release scope',
entity_ids: [ssoId, darkModeId],
})
scoreEntity(store, { exercise_id: exercise.id, entity_id: ssoId, values: { moscow: 'must' } })
// rank an exercise straight from its includes edges:
executePrioritise(rice, [], store, { exerciseId: exercise.id })applyFrameworkEnvelope(result): { exercise_id, exercise, included: { edge_id, entity_id, edge_type }[], warnings }store.setWriter(tool: string, tool_version?: string): void
applyFrameworkEnvelope serialises an applyFramework result into the single cross-surface shape that the CLI (upg apply --json) and the MCP server (apply_framework) both emit, so the contract is identical across them (0.8.7). store.setWriterrecords the writing tool and version; it is stamped into the file’s provenance as the last writer on every flush (0.8.7).
executeReflect(store: UPGFileStore, mode?: string, scope?: string | null): ReflectResult
Emits structured reflection prompts from graph topology + mode. Valid modes: assumptions, alternatives, blind-spots, load-bearing. Omit the mode (or pass null) to run all four and return the top 3.
An unrecognised mode THROWS ReflectModeError (listing the valid set); it does not silently return an empty list.
executeReflect(store, 'assumptions') // prompts to falsify each assumption / drafted hypothesis
executeReflect(store) // open reflection: top 3 across all modesUnknownEntityTypeErrorThrown by nodes.create when type is not in the canonical catalog. Deprecated aliases are accepted with a warning; genuinely unknown types throw.
WriteValidationErrorThrown by nodes.create / nodes.update when status is not in the type’s lifecycle phases (and, under { strict: true }, on unknown properties).
ReflectModeErrorThrown by executeReflect when mode is not one of assumptions / alternatives / blind-spots / load-bearing. Carries validModes.
Note: edges.connect and nodes.createMany do NOT throw on invalid input; they return error-bearing result objects (see those methods).
ENOENT (raw)Thrown by the first read or write if the underlying file doesn’t exist. Scaffold with the CLI (see Constructor note above). A friendlier wrapper is planned.
The SDK re-exports the lower-level helpers from @unified-product-graph/corefor advanced consumers: adapter authors, validator builders, anyone writing tooling that doesn’t need a live client instance.
Browse them under the Core primitives section of the sidebar: catalog, shapes, registry, grammar, properties, presentation, intelligence, playbooks, approaches, regions.