npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@granular-software/sdk

v0.4.45

Published

TypeScript SDK and CLI for Granular - define, build, and deploy AI sandboxes

Downloads

1,644

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/sdk

Inside 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.mdWhat Granular is, glossary, manifest → version → build run → connect → jobs, granular.json syntax (fields, relationships, effects), @granular-software/sdk map, 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.mdThis sandbox’s ontology snapshot: ids, exact names/keys, effect schemas, snippets. Updated after each successful granular build, granular deploy, and granular dev rebuild, and on granular document (run build to 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()`
  1. openEnvironment() — Resolve or create an ontology environment for a given userId, returning an Environment
  2. recordUser() — Optional explicit user upsert when you want the returned granularId
  3. applyManifest() — Define your domain ontology (classes, properties, relationships)
  4. recordObject() / recordObjects() — Use recordObject() for one targeted upsert. Use recordObjects() for immediate multi-record writes with chunk progress.
  5. granular.ontology(...).effects.registerMany() — Register ontology-scoped live handlers for effects declared in the ontology manifest
  6. environment.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 a publisher "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, or deny (omitted means deny).
  • extends: inherit one parent profile; children cannot define defaults.
  • policies[]: cross-action or targeted rules with decision / outcome and when.
  • actions[]: allow, confirm, deny, allowWhen, confirmWhen, denyWhen, and numeric limits.
  • Conditions can read input, target object fields, and target stateMachine state, with all, any, and not.

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 like records.

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