Skip to main content
These are the invariants and conventions that make a Penumbra integration idiomatic. They hold across every surface — SDK, REST, and MCP. If you are directing a coding agent to build against Penumbra, give it this page first.

1. Write through deltas, always

Every write stages through a delta. pb.capture and pb.extract open, stage, and apply one for you; pass apply: false to stage without committing, plan() to preview the diff, apply() to commit, and revert() to undo. Do not look for a raw write path — there isn’t one.
const receipt = await pb.extract({ source: text, shapeId, apply: false });
const plan = await pb.deltas.plan(receipt.deltaId); // review
await pb.deltas.apply(receipt.deltaId);             // commit

2. Discover before you write

The shape decides what can land. Read it first, then write inside it:
  • pb.ontology({ format: "markdown" }) — the active model as a document.
  • pb.shapes.list() — your project’s shapes. Starter shapes are not in this list: discover the curated kit with pb.shapes.starters().
  • From an agent: penumbra_introspect before the first capture.
An entity type or property that the shape does not declare has nowhere to land (under strict adherence it is rejected).

3. Address relationship endpoints explicitly

Staged relationships take typed endpoint references, not display names: from_entity_index / to_entity_index for entities staged in the same delta (by stage order), or from_node_id / to_node_id for nodes already committed.
await pb.deltas.addEntities(delta.id, [
  { type: "Account", name: "Acme", properties: { tier: "enterprise" } }, // 0
  { type: "Contact", name: "Dana", properties: { role: "CFO" } },        // 1
]);
await pb.deltas.addRelationships(delta.id, [
  { type: "WORKS_AT", from_entity_index: 1, to_entity_index: 0 },
]);

4. There are no confidence scores

Penumbra never returns a confidence number, and idiomatic code never invents one. Fitness questions get a disposition plus findings from pb.dq; evidence questions get per-claim supported / inferred / unsupported verdicts from pb.dq.trace. Branch on verdict.safeToAct and trace.grounded, not on thresholds.

5. Verify before consequential actions

Before an agent acts on graph context — sends the email, files the report, makes the recommendation — gate it:
const verdict = await pb.dq.check({ subject, purpose: "send renewal proposal" });
if (!verdict.safeToAct) {
  const plan = pb.dq.repair(verdict); // what to capture, extract, or hydrate
  // run the repairs via pb.*, then re-check
}
The same subject can be fit for one purpose and unfit for another; tie the check to the action.

6. Pick the right memory verb

  • pb.memory.remember — one explicit memory, cheap, use freely.
  • pb.memory.observe — digests a whole transcript into many memories. It runs a full extraction (model spend); use it from code at session boundaries, not per message.
  • Both land on the same memory plane, so recall — from the SDK or the Memory MCP — reads everything.

7. Materialize deliberately

shape_workbench_materialize is dry-run by default and requires a review_note. Compile, diff, and preview the operating surface first; then apply with apply: true, dry_run: false. Forking a shape creates a separate derived shape; editing changes it in place — choose deliberately.

8. Scope keys to projects

A key scoped to one project makes projectId implicit everywhere. A key that spans projects requires projectId on calls that take it. When an agent operates over MCP, have it confirm context first (penumbra_context_get) rather than assuming the active project.

9. Build in loops, not calls

Idiomatic Penumbra composes the same few loops the guides run: write → read back, register → extract → review → apply, gather → trace → check → act-or-repair. If your integration doesn’t close its loop — write something it never reads, or act on context it never verified — you are using the graph as a logbook, and it can do more than that.

Quick reference

Doing this?UseNot
Creating a delta{ name: "..." }label, purpose
Staging an entity{ type, properties, name? }top-level fields outside properties
Extractingsource: string | { text, ... }source: { id }
Finding starter shapespb.shapes.starters()pb.shapes.list()
Neighbor expansion in searchincludeNeighbors: 0 | 1 | 2true / false
Expressing uncertaintydispositions, findings, gapsconfidence scores