# @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](https://docs.granular.dev)

## Installation

```bash
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.md`** — **What 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.md`** — **This 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

```typescript
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: ["agent"],
  name: "Jane Doe", // optional
  email: "jane@example.com", // 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: "billing@acme.com", 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);  // "billing@acme.com"

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

```typescript
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:

```typescript
const env = await granular.openEnvironment({
  ontology: "library-app",
  tag: "dev",
  userId: "user_123",
  permissions: ["agent"],
});

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:

```typescript
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}`] };
    },
  },
]);
```

## Domain Synthesis (Auto-Generated Types)

After applying the manifest and publishing tools, the sandbox gets **auto-generated TypeScript classes** in `./sandbox-tools`:

```typescript
// 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:

```typescript
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:

```typescript
// 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/](./examples/) for runnable code:

- **[basic.ts](./examples/basic.ts)** — Ontology + class-based tools + domain synthesis + job execution
- **[management.ts](./examples/management.ts)** — Sandbox, permission profiles, and user management
- **[interactive.ts](./examples/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:

```ts
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
