@penumbra-systems/platform
v0.7.1
Published
The runtime client for Penumbra: typed access to your knowledge graph for capture, extraction, search, governed memory, and decision quality.
Maintainers
Readme
@penumbra-systems/platform
The runtime client for the Penumbra Platform API. It gives you typed access to your knowledge graph: capture and extract structured entities, search across them, recall and synthesize memory, and verify whether what you have is good enough to act on.
This is a preview release for design partners and developer testers. You need a Penumbra API key. Node.js 18 or newer is required.
Design time vs. run time
Penumbra splits into two surfaces, and this SDK is one of them.
- Design time: the Shapes Workbench (an MCP server). You author your ontology there: the types, shapes, and relationships that structure your graph. This is where the schema of your knowledge lives and evolves.
- Run time: this SDK. You operate against a live graph governed by those shapes: capture, extract, search, recall, and check quality. The SDK reads the ontology you designed; it does not replace the authoring experience.
If you are deciding what your knowledge looks like, you want the Workbench. If you are putting knowledge in and getting it back out from an application or agent, you want this SDK.
Install
pnpm add @penumbra-systems/platformSet your key:
export PENUMBRA_API_KEY="pnbr-..."import { createPenumbra } from "@penumbra-systems/platform";
const pb = createPenumbra({ apiKey: process.env.PENUMBRA_API_KEY! });The client targets the production API by default. You can override baseUrl in
createPenumbra({ apiKey, baseUrl }) for self-hosted or staging environments,
but most testers never need to.
Quickstart (read-only)
Nothing here writes to your graph.
// What does this project's ontology look like?
const ontology = await pb.ontology({ format: "markdown" });
console.log(ontology.markdown || ontology.content);
// Find things across entities and sources.
const hits = await pb.search("pricing objections from enterprise deals", {
include: ["entities", "chunks"],
limit: 10,
});
console.log(hits.results);What your key can do depends on its scopes (for example graph:read,
search:read, graph:write) and whether it is scoped to one project or your
whole organization. Project-scoped keys make projectId implicit; if your key
spans multiple projects, pass projectId on calls that take it.
The surface
pb is organized into the verbs you run against a governed graph:
| Call | What it does |
| ----------------------- | -------------------------------------------------------------------- |
| pb.ontology(...) | Read the active ontology (types, shapes, relationships). |
| pb.search(query, ...) | Hybrid / semantic / lexical retrieval over entities and sources. |
| pb.capture(...) | Stage a structured entity into the graph. |
| pb.extract(...) | Run text through a shape to extract entities and relationships. |
| pb.memory.* | Domain-typed memory: remember, observe, recall, synthesize, archive. |
| pb.dq.* | Decision quality: is this region of the graph fit to act on? |
| pb.types.* | Read and manage entity types. |
| pb.shapes.* | Read shapes, schemas, bindings, projections. |
| pb.deltas.* | The staged-write primitive underneath capture and extract. |
| pb.context.* | Discover, preview, and compile scoped context. |
| pb.slices.* | Named, reusable context views. |
| pb.sources.* | Upload files as sources, poll readiness, read entities and stats. |
Starter shapes
Penumbra ships a curated set of public starter shapes you can use immediately or fork to fit your domain:
const kit = await pb.shapes.starters();
// Foundation Layer (general extraction), Research, Founder Worldview, MemoryNote: pb.shapes.list() returns your project's own shapes and does not
include system shapes, so the starters won't appear there. Use
pb.shapes.starters() to discover them; they're resolvable by name in
pb.extract({ shape }) and govern pb.memory (the memory shape) without being
attached to your project.
Governed writes
Writes do not hit the graph directly. They stage through a governed delta,
which you can inspect before it commits. Pass apply: false to stage without
committing, which is the right default while you are testing.
const receipt = await pb.capture({
type: "Insight",
properties: { content: "Enterprise buyers stall on procurement, not price." },
apply: false, // staged, not committed
});
console.log(receipt.deltaId, receipt.status); // "...", "staged"pb.extract works the same way, and needs a shape that exists in the target
project plus an extraction-capable key. Keep it out of your first smoke test
unless your project has a known shape.
Sources: the headless upload loop
Get a file into Penumbra and extract from it — one key, four lines:
const src = await pb.sources.upload({ projectId, file, filename: "deck.pdf" });
await pb.sources.waitUntilReady(src.id); // polls 4-axis readiness
const run = await pb.extract({ source_file_id: src.id, shape_id, apply: true });
// poll the run via the receipt's poll path (async extraction contract)upload() orchestrates the whole flow: it requests a signed storage URL
(over-cap files fail here with a 413 before any bytes move), PUTs the bytes
directly to storage (never through pnbr.io), then registers the file — where
the server re-validates size against storage truth, compresses oversized
PDFs at ingest, applies your org's source-approval policy (RFC 385), and
triggers indexing. Indexing carries a 0.1-credit charge per upload.
If your org's approval policy is gate, API uploads land staged and wait
for a governor — check src.status before polling readiness, because staged
sources stay pending until approved.
Lower-level pieces are exposed too: pb.sources.createSignedUploadUrl(),
pb.sources.register(), pb.sources.get(id), pb.sources.list(projectId),
pb.sources.entities(id), pb.sources.entityStats(id).
Scopes for the loop
| Step | Scope required |
| -------------------------------------------------- | ----------------------------- |
| pb.sources.upload() (issue + register) | sources:write |
| pb.sources.get() / waitUntilReady() / list() | sources:read |
| pb.extract({ source_file_id }) | shapes:read + delta:write |
| apply: true on extract | delta:apply |
A headless sources key wants all five. Project-scoped keys make projectId
implicit on every call above.
Memory
pb.memory stores domain-typed memory as graph state, so what an agent learns
is queryable structure rather than opaque text.
// Recall relevant memory as a graph object.
const memory = await pb.memory.recall({
query: "what should be known before continuing?",
limit: 5,
});
console.log(memory.toMarkdown());
// Write one memory. Use apply: false while testing.
await pb.memory.remember({
type: "memory",
kind: "preference",
content: "The user prefers short briefings with explicit caveats.",
apply: false,
});The full loop is remember (write one), observe (learn from a completed
interaction), recall (read as a graph), synthesize (turn recalled memory
into a prose briefing), and archive (retire stale memory).
const briefing = await pb.memory.synthesize({
query: "what should be known before the next step?",
as: { type: "SituationBriefing", purpose: "agent-handoff" },
});
console.log(briefing.toMarkdown());Your memory shape (strong default, full override)
Out of the box, pb.memory is governed by Penumbra's opinionated public memory
shape; you don't configure anything. If you want memory to capture your domain,
fork that shape (or author your own), then bind it as the project's runtime memory
shape:
await pb.memory.binding(); // { shapeName: "memory", source: "default" }
await pb.memory.useShape(myShapeId); // point pb.memory at your shape
await pb.memory.resetShape(); // revert to the system defaultAfter useShape, remember/observe are governed by your shape. Memory is
defined by the memory plane, so recall still returns everything in it
regardless of which shape wrote it.
Decision quality
pb.dq answers a different question than search: not "what is there?" but "is
what's there good enough to act on, for this purpose?" A verdict is a
disposition plus an enumerated set of findings, never a confidence score.
const verdict = await pb.dq.check({
subject: { type: "Account", id: "acme" },
purpose: "send a renewal proposal",
});
if (!verdict.safeToAct) {
console.log(verdict.explain());
console.log(pb.dq.repair(verdict)); // suggested remediation steps
}Errors
Failed requests throw PenumbraPlatformError with status, code, and the
raw response data.
import { PenumbraPlatformError } from "@penumbra-systems/platform";
try {
await pb.search("...");
} catch (err) {
if (err instanceof PenumbraPlatformError) {
console.error(err.status, err.code, err.message);
}
}