@granular-software/sdk
v0.4.8
Published
TypeScript SDK and CLI for Granular - define, build, and deploy AI sandboxes
Downloads
1,284
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>
Quick Start
import { Granular, type ManifestContent } from '@granular-software/sdk';
const granular = new Granular({ apiKey: process.env.GRANULAR_API_KEY });
// 1. Connect to sandbox for one of your app users
const env = await granular.connect({
sandbox: 'my-sandbox',
userId: 'user_123',
permissions: ['agent'],
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 build manifest
await granular.registerEffects(env.sandboxId, [
{
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.00, invoices: 3, period: params?.period || 'current' };
},
},
]);
// 6. Submit a job — the sandbox gets fully typed OOP classes!
const job = await env.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 sandbox build manifest with withEffect. Live registration only makes already-declared effects available at runtime.
Core Flow
declare effects in build manifest → connect({ userId }) → recordObject() → registerEffects() → submitJob()connect()— Connect to a sandbox for a givenuserId, returning anEnvironmentrecordUser()— Optional explicit user upsert when you want the returnedgranularIdapplyManifest()— Define your domain ontology (classes, properties, relationships)recordObject()— Create/update instances of your classes with fields and relationshipsgranular.registerEffects()— Register sandbox-scoped live handlers for effects declared in the build manifestsubmitJob()— Execute code in the sandbox that uses the auto-generated typed classes
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.connect({
sandbox: 'library-app',
userId: 'user_123',
permissions: ['agent'],
});
const tolkien = await env.recordObject({
className: 'author',
id: 'tolkien', // Real-world ID (unique per class)
label: 'J.R.R. Tolkien',
fields: { name: 'J.R.R. Tolkien', birth_year: 1892 },
});
// tolkien.path → 'author_tolkien' (internal graph path, unique globally)
// tolkien.id → 'tolkien' (real-world ID)
const lotr = await env.recordObject({
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
});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.
Effect Definitions
Effects are declared in the manifest with withEffect, then their live handlers are registered at sandbox scope. Effects can be instance methods, static methods, or global functions. Both inputSchema and outputSchema use JSON Schema:
await granular.registerEffects(env.sandboxId, [
// 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}`] };
},
},
]);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 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>;
/** List known Author instances */
static list(query?: { limit?: number; saveAs?: string; refresh?: boolean }): Promise<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 list(query?: { limit?: number; saveAs?: string; refresh?: boolean }): Promise<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 authors = await Author.list();
const tolkien = authors.find((author) => author.name === 'J.R.R. Tolkien');
if (!tolkien) throw new Error('Author not found');
console.log(tolkien.name); // "J.R.R. Tolkien"
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.connect(options)
Connects to a sandbox session for the specified user.
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.getRelationships(modelPath)
Returns relationship definitions for a given class.
environment.listRelated(modelPath, submodelPath)
Lists related instances through a relationship.
granular.registerEffects(sandboxId, effects)
Registers sandbox-scoped live handlers for effects declared in the sandbox build manifest. Effects can be instance methods (className set, static omitted), static methods (static: true), or global functions (no className).
environment.submitJob(code)
Submits code to be executed in the sandbox. The code imports typed classes from ./sandbox-tools.
environment.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.
environment.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
