@granular-software/sdk
v0.4.45
Published
TypeScript SDK and CLI for Granular - define, build, and deploy AI sandboxes
Downloads
1,644
Maintainers
Readme
@granular-software/sdk
The official TypeScript SDK for Granular.
Build AI-powered domain models with secure, isolated execution. Granular lets you define ontologies (classes, relationships), attach tools to them, and run AI-generated code that operates on typed domain objects — all in a sandboxed environment with per-user permissions.
Features
- 🏗️ Domain Ontology — Define classes, properties, and relationships as a typed graph
- 🔧 Class-Based Tools — Attach instance methods, static methods, and global tools with typed I/O
- 🤖 Domain Synthesis — Auto-generated TypeScript classes with
get({ path }),list(), relationship accessors, and typed methods - 🔒 Secure Execution — Run AI-generated code in isolated sandboxes
- 👥 User Permissions — Control what each user can access
- ⚡ Real-time — WebSocket-based streaming and events
- ↩️ Reverse RPC — Sandbox code calls tools that execute on your server
Documentation
Full documentation: docs.granular.dev
Installation
bun add @granular-software/sdk
# or
npm install @granular-software/sdkInside this monorepo, prefer the workspace package instead of installing from npm separately.
Endpoint Modes
By default, the SDK resolves endpoints like this:
- Local mode (
NODE_ENV=development):ws://localhost:8787/granular - Production mode (default):
wss://cf-api-gateway.arthur6084.workers.dev/granular
Overrides:
- SDK option:
endpointMode: 'local' | 'production' - SDK option:
apiUrl: 'ws://... | wss://...'(highest priority) - Env:
GRANULAR_ENDPOINT_MODE=local|production - Env:
GRANULAR_API_URL=...(highest priority)
CLI overrides:
granular --local <command>granular --prod <command>granular --env local|production <command>
When the resolved API URL points at localhost or 127.0.0.1, the SDK and CLI automatically swap opaque sk_... WorkOS keys for the local dev key gn_sk_tenant_default_principal_local_e2e_00000000 so local runs work without server-side WorkOS validation. Override with GRANULAR_LOCAL_API_KEY=... or disable with GRANULAR_DISABLE_LOCAL_API_KEY_FALLBACK=true.
Agent documentation (CLI)
Projects created with granular init can include Markdown for coding agents (optional AGENTS.md plus generated guides):
docs/granular-manifest.md— What Granular is, glossary, manifest → version → build run → connect → jobs,granular.jsonsyntax (fields, relationships, effects),@granular-software/sdkmap, CLI. Self-contained for agents new to the product. Added when you opt in at init (--agent-docs/--no-agent-docs, or the prompt).GRANULAR_SANDBOX.md— This sandbox’s ontology snapshot: ids, exact names/keys, effect schemas, snippets. Updated after each successfulgranular build,granular deploy, andgranular devrebuild, and ongranular document(runbuildto record version/build metadata).AGENTS.md— Two-step index: manifest guide first, then sandbox doc (merged idempotently with<!-- granular-sdk:begin -->).
Quick Start
import { Granular, type ManifestContent } from "@granular-software/sdk";
const granular = new Granular({ apiKey: process.env.GRANULAR_API_KEY });
// 1. Connect to the default dev environment for one of your app users
const env = await granular.openEnvironment({
ontology: "my-ontology",
tag: "dev",
userId: "user_123",
permissions: ["allow-all"],
name: "Jane Doe", // optional
email: "[email protected]", // optional
});
// 2. Define your domain ontology
const manifest: ManifestContent = {
schemaVersion: 2,
name: "my-app",
volumes: [
{
name: "schema",
scope: "sandbox",
imports: [{ alias: "@std", name: "standard_modules", label: "prod" }],
operations: [
// Define classes with typed properties
{
create: "customer",
extends: "@std/class",
has: {
name: { type: "string", description: "Customer name" },
email: { type: "string", description: "Email address" },
tier: { type: "string", description: "Subscription tier" },
},
},
{
create: "order",
extends: "@std/class",
has: {
total: { type: "number", description: "Order total" },
status: { type: "string", description: "Order status" },
},
},
// Define relationships
{
defineRelationship: {
left: "customer",
right: "order",
leftSubmodel: "orders",
rightSubmodel: "customer",
leftIsMany: true,
rightIsMany: false,
},
},
],
},
],
};
await env.applyManifest(manifest);
// 3. Record object instances
await env.recordObject({
className: "customer",
id: "cust_42",
label: "Acme Corp",
fields: { name: "Acme Corp", email: "[email protected]", tier: "enterprise" },
});
// 4. Register live effect handlers for effects already declared in the ontology manifest
await granular.ontology(env.sandboxId).effects.registerMany([
{
name: "get_billing_summary",
description: "Get billing summary for a customer",
className: "customer", // Attached to Customer class
// static omitted → instance method (receives object ID automatically)
inputSchema: {
type: "object",
properties: {
period: {
type: "string",
description: 'Billing period (e.g. "2024-Q1")',
},
},
},
outputSchema: {
type: "object",
properties: {
total: { type: "number", description: "Total billed amount" },
invoices: { type: "number", description: "Number of invoices" },
period: { type: "string", description: "The billing period" },
},
required: ["total", "invoices"],
},
handler: async (customerId: string, params: any, ctx: any) => {
// customerId comes from `this.id` in sandbox code
console.log("Running for subject:", ctx.user.subjectId);
return {
total: 4250.0,
invoices: 3,
period: params?.period || "current",
};
},
},
]);
// 6. Submit a job — the sandbox gets fully typed OOP classes!
const session = await env.sessions.create();
const job = await session.submitJob(`
import { Customer } from './sandbox-tools';
// list() discovers instances; get({ path }) hydrates a specific graph object
const customers = await Customer.list();
const acme = customers.find((customer) => customer.name === 'Acme Corp');
if (!acme) throw new Error('Customer not found');
console.log(acme.name); // "Acme Corp"
console.log(acme.email); // "[email protected]"
// Instance method — typed input AND output
const billing = await acme.get_billing_summary({ period: '2024-Q1' });
// billing.total, billing.invoices, billing.period are all typed
// Navigate relationships
const orders = await acme.get_orders();
return { customer: acme.name, billing, orderCount: orders.length };
`);
const result = await job.result;Effects must be declared ahead of time in the ontology version manifest with withEffect. Live registration only makes already-declared effects available at runtime.
Core Flow
declare effects in the manifest → `granular build` creates or reuses a version → `openEnvironment({ ontology, tag, userId })` resolves the user's environment → `recordObject()` or `recordObjects()` → `granular.ontology(...).effects.registerMany()` → `environment.sessions.create()` → `session.submitJob()`openEnvironment()— Resolve or create an ontology environment for a givenuserId, returning anEnvironmentrecordUser()— Optional explicit user upsert when you want the returnedgranularIdapplyManifest()— Define your domain ontology (classes, properties, relationships)recordObject()/recordObjects()— UserecordObject()for one targeted upsert. UserecordObjects()for immediate multi-record writes with chunk progress.granular.ontology(...).effects.registerMany()— Register ontology-scoped live handlers for effects declared in the ontology manifestenvironment.sessions.create()/session.submitJob()— Open a live session and execute code against the sandbox runtime
Defining the Domain Ontology
Use applyManifest() to declare classes, typed properties, and relationships:
const manifest: ManifestContent = {
schemaVersion: 2,
name: "library-app",
volumes: [
{
name: "schema",
scope: "sandbox",
imports: [{ alias: "@std", name: "standard_modules", label: "prod" }],
operations: [
// Classes with typed properties
{
create: "author",
extends: "@std/class",
has: {
name: { type: "string", description: "Author full name" },
birth_year: { type: "number", description: "Year of birth" },
},
},
{
create: "book",
extends: "@std/class",
has: {
title: { type: "string", description: "Book title" },
isbn: { type: "string", description: "ISBN number" },
published_year: { type: "number", description: "Year published" },
},
},
// Relationships (bidirectional, with cardinality)
{
defineRelationship: {
left: "author",
right: "book",
leftSubmodel: "books",
rightSubmodel: "author",
leftIsMany: true,
rightIsMany: false,
// → author.books (one-to-many), book.author (many-to-one)
},
},
],
},
],
};
await env.applyManifest(manifest);Recording Object Instances
After defining the ontology, connect as a user and populate it with data:
const env = await granular.openEnvironment({
ontology: "library-app",
tag: "dev",
userId: "user_123",
permissions: ["allow-all"],
});
const [tolkien, lotr] = await env.recordObjects(
[
{
className: "author",
id: "tolkien", // Real-world ID (unique per class)
label: "J.R.R. Tolkien",
fields: { name: "J.R.R. Tolkien", birth_year: 1892 },
},
{
className: "book",
id: "lotr",
label: "The Lord of the Rings",
fields: {
title: "The Lord of the Rings",
isbn: "978-0-618-64015-7",
published_year: 1954,
},
relationships: { author: "tolkien" }, // Real-world ID — SDK resolves it automatically
},
],
{
onChunkComplete(info) {
console.log(`Committed chunk ${info.chunkIndex + 1}/${info.totalChunks}`);
},
},
);
// tolkien.path → 'author_tolkien' (internal graph path, unique globally)
// tolkien.id → 'tolkien' (real-world ID)
// lotr.created → true (false means the record already existed and was updated)Cross-class ID uniqueness: Two objects of different classes can share the same real-world ID (e.g., an
author"tolkien" and apublisher"tolkien"). The SDK derives unique graph paths internally (author_tolkien,publisher_tolkien) so they never collide.
For one-off writes, recordObject(...) is still the clearest choice. For large fire-and-forget imports, use enqueueRecordImport(...) and poll getRecordImport(...) / getRecordImportSummary() for aggregate progress.
Effect Definitions
Effects are declared in the manifest with withEffect, then their live handlers are registered at ontology scope. Effects can be instance methods, static methods, or global functions. Both inputSchema and outputSchema use JSON Schema:
await granular.ontology(env.sandboxId).effects.registerMany([
// Instance method: called as `tolkien.get_bio({ detailed: true })`
// Handler receives (objectId, params)
{
name: "get_bio",
description: "Get biography of an author",
className: "author", // Attached to Author class
// static: false (default) // Instance method
inputSchema: {
type: "object",
properties: {
detailed: { type: "boolean", description: "Include full details" },
},
},
outputSchema: {
type: "object",
properties: {
bio: { type: "string", description: "The biography text" },
source: { type: "string", description: "Source of the bio" },
},
required: ["bio"],
},
handler: async (id: string, params: any, ctx: any) => {
return { bio: `Biography of ${id}`, source: "database" };
},
},
// Static method: called as `Author.search({ query: 'tolkien' })`
// Handler receives (params) — no object ID
{
name: "search",
description: "Search for authors",
className: "author",
static: true,
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
outputSchema: {
type: "object",
properties: { results: { type: "array" } },
},
handler: async (params: any, ctx: any) => {
return { results: [`Found: ${params.query}`] };
},
},
// Global tool: called as `global_search({ query: 'rings' })`
// No className → standalone exported function
{
name: "global_search",
description: "Search across everything",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
outputSchema: {
type: "object",
properties: { results: { type: "array" } },
},
handler: async (params: any, ctx: any) => {
return { results: [`Result for: ${params.query}`] };
},
},
]);Permission Profiles and Policies
Policies are versioned with the ontology. Author role profiles as JSON files under permissions/, then run granular build or granular deploy; the CLI validates and syncs those files before the build.
Built-in profiles are materialized by granular init and granular pull:
allow-all: exposes every declared action unless manifest logic denies it.confirm-all: exposes every declared action but requires confirmation.deny-all: exposes no executable actions.
Manifest-level logic policies live on effects and apply to every profile:
{
"withEffect": {
"name": "approve_ticket",
"attachedClass": "ticket",
"inputSchema": { "type": "object", "properties": { "note": { "type": "string" } } },
"outputSchema": { "type": "object", "properties": { "ok": { "type": "boolean" } } },
"metamodels": {
"policies": {
"denyWhen": [
{
"reason": "Locked tickets cannot be approved",
"when": { "object": { "path": "locked", "operator": "eq", "booleanValue": true } }
}
]
}
}
}
}Permission profile files define role-specific access:
{
"schemaVersion": 1,
"name": "reviewer",
"defaults": { "actionPolicy": "deny" },
"policies": [
{
"action": "approve_ticket",
"on": "ticket",
"decision": "allow",
"reason": "Active low-risk tickets can be approved",
"when": {
"all": [
{ "object": { "path": "risk_score", "operator": "lte", "numberValue": 50 } },
{ "stateMachine": { "machine": "lifecycle", "operator": "eq", "stringValue": "active" } }
]
}
}
],
"actions": [
{ "action": "comment_ticket", "on": "ticket", "allow": "always" }
]
}Supported profile features:
defaults.actionPolicy:allow,confirm, ordeny(omitted meansdeny).extends: inherit one parent profile; children cannot definedefaults.policies[]: cross-action or targeted rules withdecision/outcomeandwhen.actions[]:allow,confirm,deny,allowWhen,confirmWhen,denyWhen, and numericlimits.- Conditions can read
input, targetobjectfields, and targetstateMachinestate, withall,any, andnot.
Decision precedence is deterministic: manifest deny, profile deny, confirmation rules, profile allow, then the profile default. Runtime blocks denied actions before the effect provider is invoked; confirmation rules use the normal human prompt flow.
Useful CLI checks:
granular permissions validate
granular permissions preview --profile reviewer --action approve_ticket --on ticket \
--input '{"note":"ok"}' \
--object '{"risk_score":40,"locked":false}' \
--state-machines '{"lifecycle":"active"}'Domain Synthesis (Auto-Generated Types)
After applying the manifest and publishing tools, the sandbox gets auto-generated TypeScript classes in ./sandbox-tools:
// What the sandbox sees (auto-generated):
export interface SandboxPageResult<T> {
items: T[];
page: number;
perPage: number;
totalCount: number;
hasMore: boolean;
}
export declare class Author {
readonly id: string;
readonly name: string;
readonly birth_year: number;
constructor(id: string, fields?: Record<string, any>);
/** Get a cached Author by graph path, hydrating from the graph when needed */
static get(query: {
path: string;
refresh?: boolean;
}): Promise<Author | null>;
/** Count Author instances without loading them into the heap */
static count(): Promise<number>;
/** Return one page of Author instances together with pagination metadata */
static page(query?: {
page?: number;
perPage?: number;
limit?: number;
saveAs?: string;
refresh?: boolean;
}): Promise<SandboxPageResult<Author>>;
/** List one page of Author instances */
static list(query?: {
page?: number;
perPage?: number;
limit?: number;
saveAs?: string;
refresh?: boolean;
}): Promise<Author[]>;
/** Stream Author instances page by page */
static iterate(query?: {
page?: number;
perPage?: number;
limit?: number;
maxItems?: number;
refresh?: boolean;
}): AsyncIterable<Author>;
/** Get biography of an author */
get_bio(input?: {
detailed?: boolean;
}): Promise<{ bio: string; source?: string }>;
/** Search for authors (static) */
static search(input: { query: string }): Promise<{ results?: any[] }>;
/** Navigate to books (one_to_many) */
get_books(): Promise<Book[]>;
}
export declare class Book {
readonly id: string;
readonly title: string;
readonly isbn: string;
readonly published_year: number;
static get(query: { path: string; refresh?: boolean }): Promise<Book | null>;
static count(): Promise<number>;
static page(query?: {
page?: number;
perPage?: number;
limit?: number;
saveAs?: string;
refresh?: boolean;
}): Promise<SandboxPageResult<Book>>;
static list(query?: {
page?: number;
perPage?: number;
limit?: number;
saveAs?: string;
refresh?: boolean;
}): Promise<Book[]>;
static iterate(query?: {
page?: number;
perPage?: number;
limit?: number;
maxItems?: number;
refresh?: boolean;
}): AsyncIterable<Book>;
/** Navigate to author (many_to_one) */
get_author(): Promise<Author | null>;
}
/** Search across everything */
export declare function global_search(input: {
query: string;
}): Promise<{ results?: any[] }>;The LLM or user writes code against these typed classes:
import { Author, Book, global_search } from "./sandbox-tools";
const totalAuthors = await Author.count();
const firstPage = await Author.page({ page: 1, perPage: 25 });
const authors = firstPage.items;
const tolkien = authors.find((author) => author.name === "J.R.R. Tolkien");
if (!tolkien) throw new Error("Author not found");
console.log(totalAuthors, firstPage.hasMore);
console.log(tolkien.name); // "J.R.R. Tolkien"
for await (const author of Author.iterate({ perPage: 100, maxItems: 200 })) {
console.log(author.id);
}
const bio = await tolkien.get_bio({ detailed: true });
console.log(bio.bio); // typed as string
const books = await tolkien.get_books();
for (const book of books) {
console.log(book.title, book.isbn); // typed properties
}
const results = await Author.search({ query: "tolkien" });
const globalResults = await global_search({ query: "rings" });GraphQL API
Every environment has a built-in GraphQL API for direct graph queries:
// Authenticated automatically with your API key
const result = await env.graphql(
`query { model(path: "author") { path label submodels { path label } } }`,
);
// The endpoint is also available as a URL
console.log(env.apiEndpoint);Framework Adapters
Framework-specific adapters are temporarily unavailable while the SDK build and release flow is being stabilized in the monorepo.
Examples
See examples/ for runnable code:
- basic.ts — Ontology + class-based tools + domain synthesis + job execution
- management.ts — Sandbox, permission profiles, and user management
- interactive.ts — Human-in-the-loop prompts
API Reference
granular.recordUser(options)
Registers a user identity and their assigned permission profiles.
granular.openEnvironment(options)
Resolves or creates an environment handle for the specified user. Pass ontology and a tag such as tag: 'dev' or tag: 'prod'.
environment.applyManifest(manifest)
Defines domain ontology: classes (with typed properties), and relationships (with cardinality).
environment.recordObject(options)
Creates or updates a class instance with fields and relationships. Returns { path, id, created }.
environment.recordObjects(records, options?)
Batch upsert for many instances. The SDK sends chunks (default 100 rows per HTTP POST to /records/batch) with retries on transient failures, so large arrays do not time out as a single oversized request.
Optional options:
batchSize— max rows per request (default 100).concurrency— how many chunk requests may run in parallel (default 1, max 16); can reduce wall time when the server can overlap work.onChunkComplete— async-friendly hook after each chunk for progress UIs; the returned array is always ordered likerecords.
Sync batch vs queued import: use recordObjects when you need synchronous commits and/or per-chunk feedback. Use enqueueRecordImport + getRecordImport / getRecordImportSummary for background ingestion with aggregate counters when admission latency matters more than immediate row-by-row completion.
environment.getRelationships(modelPath)
Returns relationship definitions for a given class.
environment.listRelated(modelPath, submodelPath)
Lists related instances through a relationship.
granular.ontology(sandboxId).effects.registerMany(effects)
Registers ontology-scoped live handlers for effects declared in the ontology manifest. Effects can be instance methods (className set, static omitted), static methods (static: true), or global functions (no className).
environment.sessions.create(options?) / session.submitJob(code)
Open a live runtime session for the environment, then submit code to the sandbox. The code imports typed classes from ./sandbox-tools.
Live Session State And Durable Collections
session.document and session.getHeap() are local, fast views of the live runtime state synced over the WebSocket.
Older conversation history, timeline events, completed jobs, and saved heap artifacts are exposed through named durable collections:
const session = await env.sessions.create();
const liveHeap = session.getHeap();
const messages = await session.messages.list({ limit: 100 });
const timeline = await session.timeline.list({ limit: 100 });
const jobs = await session.jobs.list({ status: "all", limit: 100 });
const job = await session.jobs.get("job_123");
const entry = await session.heap.entries.get("customer_123");
const savedList = await session.heap.lists.get("recent_customers");
const transcript = await session.transcript.list({ limit: 100 });Use the live state for current working context, and the collection APIs when you need durable history or artifacts that may have been moved out of the live document.
session.getDomainDocumentation()
Get auto-generated TypeScript class declarations. Pass this to LLMs to help them write correct code.
environment.graphql(query, variables?)
Execute a GraphQL query against the environment's graph. Authenticated automatically.
session.on(event, handler)
Listen for events: 'effect:invoke', 'effect:result', 'job:status', 'stdout', etc. Legacy 'tool:*' aliases still exist internally but are no longer the primary model.
Environment.toGraphPath(className, id)
Convert a class name + real-world ID to a unique graph path ({className}_{id}).
Environment.extractIdFromGraphPath(graphPath, className)
Extract the real-world ID from a graph path by stripping the class prefix.
License
MIT
License
MIT
