# @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.connect({
  ontology: 'my-ontology',
  environment: '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.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);  // "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 → `connect({ ontology, environment, userId })` opens a named environment → `recordObject()` → `registerEffects()` → `submitJob()`
```

1. **`connect()`** — Connect to 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()`** — Create/update instances of your classes with fields and relationships
5. **`granular.registerEffects()`** — Register sandbox-scoped live handlers for effects declared in the ontology manifest
6. **`submitJob()`** — 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:

```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.connect({
  ontology: 'library-app',
  environment: 'dev',
  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 a `publisher` "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:

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

```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.connect(options)`
Connects to an environment session for the specified user. Pass `ontology` and `environment` explicitly, for example `environment: 'dev'` or `environment: 'prod'`. Only pass `tagName` for advanced overrides.

### `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.registerEffects(sandboxId, effects)`
Registers sandbox-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.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
