@granular-software/sdk 0.3.4 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,26 @@ bun add @granular-software/sdk
27
27
  npm install @granular-software/sdk
28
28
  ```
29
29
 
30
+ ## Endpoint Modes
31
+
32
+ By default, the SDK resolves endpoints like this:
33
+
34
+ - Local mode (`NODE_ENV=development`): `ws://localhost:8787/granular`
35
+ - Production mode (default): `wss://api.granular.dev/v2/ws`
36
+
37
+ Overrides:
38
+
39
+ - SDK option: `endpointMode: 'local' | 'production'`
40
+ - SDK option: `apiUrl: 'ws://... | wss://...'` (highest priority)
41
+ - Env: `GRANULAR_ENDPOINT_MODE=local|production`
42
+ - Env: `GRANULAR_API_URL=...` (highest priority)
43
+
44
+ CLI overrides:
45
+
46
+ - `granular --local <command>`
47
+ - `granular --prod <command>`
48
+ - `granular --env local|production <command>`
49
+
30
50
  ## Quick Start
31
51
 
32
52
  ```typescript
@@ -92,8 +112,8 @@ await env.recordObject({
92
112
  fields: { name: 'Acme Corp', email: 'billing@acme.com', tier: 'enterprise' },
93
113
  });
94
114
 
95
- // 5. Publish tools with typed input AND output schemas
96
- await env.publishTools([
115
+ // 5. Register live effect handlers for effects already declared in the build manifest
116
+ await granular.registerEffects(env.sandboxId, [
97
117
  {
98
118
  name: 'get_billing_summary',
99
119
  description: 'Get billing summary for a customer',
@@ -114,8 +134,9 @@ await env.publishTools([
114
134
  },
115
135
  required: ['total', 'invoices'],
116
136
  },
117
- handler: async (customerId: string, params: any) => {
137
+ handler: async (customerId: string, params: any, ctx: any) => {
118
138
  // customerId comes from `this.id` in sandbox code
139
+ console.log('Running for subject:', ctx.user.subjectId);
119
140
  return { total: 4250.00, invoices: 3, period: params?.period || 'current' };
120
141
  },
121
142
  },
@@ -143,17 +164,19 @@ const job = await env.submitJob(`
143
164
  const result = await job.result;
144
165
  ```
145
166
 
167
+ Effects must be declared ahead of time in the sandbox build manifest with `withEffect`. Live registration only makes already-declared effects available at runtime.
168
+
146
169
  ## Core Flow
147
170
 
148
171
  ```
149
- recordUser() connect()applyManifest() → recordObject() → publishTools() → submitJob()
172
+ declare effects in build manifest connect() → recordObject() → registerEffects() → submitJob()
150
173
  ```
151
174
 
152
175
  1. **`recordUser()`** — Register a user and their permission profiles
153
176
  2. **`connect()`** — Connect to a sandbox, returning an `Environment`
154
177
  3. **`applyManifest()`** — Define your domain ontology (classes, properties, relationships)
155
178
  4. **`recordObject()`** — Create/update instances of your classes with fields and relationships
156
- 5. **`publishTools()`** — Publish tool schemas (with `inputSchema` + `outputSchema`) and register local handlers
179
+ 5. **`granular.registerEffects()`** — Register sandbox-scoped live handlers for effects declared in the build manifest
157
180
  6. **`submitJob()`** — Execute code in the sandbox that uses the auto-generated typed classes
158
181
 
159
182
  ## Defining the Domain Ontology
@@ -229,12 +252,12 @@ const lotr = await env.recordObject({
229
252
 
230
253
  > **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.
231
254
 
232
- ## Tool Definitions
255
+ ## Effect Definitions
233
256
 
234
- Tools can be **instance methods**, **static methods**, or **global functions**. Both `inputSchema` and `outputSchema` use JSON Schema:
257
+ 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:
235
258
 
236
259
  ```typescript
237
- await env.publishTools([
260
+ await granular.registerEffects(env.sandboxId, [
238
261
  // Instance method: called as `tolkien.get_bio({ detailed: true })`
239
262
  // Handler receives (objectId, params)
240
263
  {
@@ -256,7 +279,7 @@ await env.publishTools([
256
279
  },
257
280
  required: ['bio'],
258
281
  },
259
- handler: async (id: string, params: any) => {
282
+ handler: async (id: string, params: any, ctx: any) => {
260
283
  return { bio: `Biography of ${id}`, source: 'database' };
261
284
  },
262
285
  },
@@ -277,7 +300,7 @@ await env.publishTools([
277
300
  type: 'object',
278
301
  properties: { results: { type: 'array' } },
279
302
  },
280
- handler: async (params: any) => {
303
+ handler: async (params: any, ctx: any) => {
281
304
  return { results: [`Found: ${params.query}`] };
282
305
  },
283
306
  },
@@ -296,7 +319,7 @@ await env.publishTools([
296
319
  type: 'object',
297
320
  properties: { results: { type: 'array' } },
298
321
  },
299
- handler: async (params: any) => {
322
+ handler: async (params: any, ctx: any) => {
300
323
  return { results: [`Result for: ${params.query}`] };
301
324
  },
302
325
  },
@@ -450,8 +473,8 @@ Returns relationship definitions for a given class.
450
473
  ### `environment.listRelated(modelPath, submodelPath)`
451
474
  Lists related instances through a relationship.
452
475
 
453
- ### `environment.publishTools(tools)`
454
- Publishes tool schemas (with `inputSchema` + `outputSchema`) and registers handlers locally. Tools can be instance methods (`className` set, `static` omitted), static methods (`static: true`), or global functions (no `className`).
476
+ ### `granular.registerEffects(sandboxId, effects)`
477
+ 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`).
455
478
 
456
479
  ### `environment.submitJob(code)`
457
480
  Submits code to be executed in the sandbox. The code imports typed classes from `./sandbox-tools`.
@@ -463,7 +486,7 @@ Get auto-generated TypeScript class declarations. Pass this to LLMs to help them
463
486
  Execute a GraphQL query against the environment's graph. Authenticated automatically.
464
487
 
465
488
  ### `environment.on(event, handler)`
466
- Listen for events: `'tool:invoke'`, `'tool:result'`, `'job:status'`, `'stdout'`, etc.
489
+ Listen for events: `'effect:invoke'`, `'effect:result'`, `'job:status'`, `'stdout'`, etc. Legacy `'tool:*'` aliases still exist internally but are no longer the primary model.
467
490
 
468
491
  ### `Environment.toGraphPath(className, id)`
469
492
  Convert a class name + real-world ID to a unique graph path (`{className}_{id}`).
@@ -1,4 +1,4 @@
1
- import { T as ToolWithHandler } from '../types-D5B8WlF4.mjs';
1
+ import { T as ToolWithHandler } from '../types-C0AVRsVR.mjs';
2
2
 
3
3
  /**
4
4
  * Anthropic tool_use block from message response
@@ -1,4 +1,4 @@
1
- import { T as ToolWithHandler } from '../types-D5B8WlF4.js';
1
+ import { T as ToolWithHandler } from '../types-C0AVRsVR.js';
2
2
 
3
3
  /**
4
4
  * Anthropic tool_use block from message response
@@ -1,5 +1,5 @@
1
1
  import { StructuredTool } from '@langchain/core/tools';
2
- import { T as ToolWithHandler } from '../types-D5B8WlF4.mjs';
2
+ import { T as ToolWithHandler } from '../types-C0AVRsVR.mjs';
3
3
 
4
4
  /**
5
5
  * Helper to convert LangChain tools to Granular tools.
@@ -1,5 +1,5 @@
1
1
  import { StructuredTool } from '@langchain/core/tools';
2
- import { T as ToolWithHandler } from '../types-D5B8WlF4.js';
2
+ import { T as ToolWithHandler } from '../types-C0AVRsVR.js';
3
3
 
4
4
  /**
5
5
  * Helper to convert LangChain tools to Granular tools.
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { T as ToolWithHandler } from '../types-D5B8WlF4.mjs';
2
+ import { T as ToolWithHandler } from '../types-C0AVRsVR.mjs';
3
3
 
4
4
  /**
5
5
  * Mastra tool definition interface
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { T as ToolWithHandler } from '../types-D5B8WlF4.js';
2
+ import { T as ToolWithHandler } from '../types-C0AVRsVR.js';
3
3
 
4
4
  /**
5
5
  * Mastra tool definition interface
@@ -1,4 +1,4 @@
1
- import { T as ToolWithHandler } from '../types-D5B8WlF4.mjs';
1
+ import { T as ToolWithHandler } from '../types-C0AVRsVR.mjs';
2
2
 
3
3
  /**
4
4
  * OpenAI tool call from chat completion response
@@ -1,4 +1,4 @@
1
- import { T as ToolWithHandler } from '../types-D5B8WlF4.js';
1
+ import { T as ToolWithHandler } from '../types-C0AVRsVR.js';
2
2
 
3
3
  /**
4
4
  * OpenAI tool call from chat completion response
package/dist/cli/index.js CHANGED
@@ -5384,6 +5384,57 @@ var {
5384
5384
 
5385
5385
  // src/cli/config.ts
5386
5386
  var import_dotenv = __toESM(require_main());
5387
+
5388
+ // src/endpoints.ts
5389
+ var LOCAL_API_URL = "ws://localhost:8787/granular";
5390
+ var PRODUCTION_API_URL = "wss://api.granular.dev/v2/ws";
5391
+ var LOCAL_AUTH_URL = "http://localhost:3000";
5392
+ var PRODUCTION_AUTH_URL = "https://app.granular.software";
5393
+ function readEnv(name) {
5394
+ if (typeof process === "undefined" || !process.env) return void 0;
5395
+ return process.env[name];
5396
+ }
5397
+ function normalizeMode(value) {
5398
+ if (!value) return void 0;
5399
+ const normalized = value.trim().toLowerCase();
5400
+ if (normalized === "local") return "local";
5401
+ if (normalized === "prod" || normalized === "production") return "production";
5402
+ if (normalized === "auto") return "auto";
5403
+ return void 0;
5404
+ }
5405
+ function isTruthy(value) {
5406
+ if (!value) return false;
5407
+ const normalized = value.trim().toLowerCase();
5408
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
5409
+ }
5410
+ function resolveEndpointMode(explicitMode) {
5411
+ const explicit = normalizeMode(explicitMode);
5412
+ if (explicit === "local" || explicit === "production") {
5413
+ return explicit;
5414
+ }
5415
+ const envMode = normalizeMode(readEnv("GRANULAR_ENDPOINT_MODE") || readEnv("GRANULAR_ENV"));
5416
+ if (envMode === "local" || envMode === "production") {
5417
+ return envMode;
5418
+ }
5419
+ if (isTruthy(readEnv("GRANULAR_USE_LOCAL_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_LOCAL"))) {
5420
+ return "local";
5421
+ }
5422
+ if (isTruthy(readEnv("GRANULAR_USE_PRODUCTION_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_PROD"))) {
5423
+ return "production";
5424
+ }
5425
+ return readEnv("NODE_ENV") === "development" ? "local" : "production";
5426
+ }
5427
+ function resolveApiUrl(explicitApiUrl, mode) {
5428
+ return resolveEndpointMode(mode) === "local" ? LOCAL_API_URL : PRODUCTION_API_URL;
5429
+ }
5430
+ function resolveAuthUrl(explicitAuthUrl, mode) {
5431
+ if (explicitAuthUrl) {
5432
+ return explicitAuthUrl;
5433
+ }
5434
+ return resolveEndpointMode(mode) === "local" ? LOCAL_AUTH_URL : PRODUCTION_AUTH_URL;
5435
+ }
5436
+
5437
+ // src/cli/config.ts
5387
5438
  var MANIFEST_FILE = "granular.json";
5388
5439
  var RC_FILE = ".granularrc";
5389
5440
  var ENV_LOCAL_FILE = ".env.local";
@@ -5453,16 +5504,17 @@ function loadApiKey() {
5453
5504
  return void 0;
5454
5505
  }
5455
5506
  function loadApiUrl() {
5507
+ if (process.env.GRANULAR_API_URL) return process.env.GRANULAR_API_URL;
5508
+ const modeOverride = process.env.GRANULAR_ENDPOINT_MODE;
5509
+ if (modeOverride === "local" || modeOverride === "production" || modeOverride === "prod") {
5510
+ return resolveApiUrl(void 0, modeOverride === "prod" ? "production" : modeOverride);
5511
+ }
5456
5512
  const rc = readRcFile();
5457
5513
  if (rc.apiUrl) return rc.apiUrl;
5458
- if (process.env.GRANULAR_API_URL) return process.env.GRANULAR_API_URL;
5459
- return "https://cf-api-gateway.arthur6084.workers.dev/granular";
5514
+ return resolveApiUrl();
5460
5515
  }
5461
5516
  function loadAuthUrl() {
5462
- if (process.env.GRANULAR_AUTH_URL) {
5463
- return process.env.GRANULAR_AUTH_URL;
5464
- }
5465
- return "https://app.granular.software";
5517
+ return resolveAuthUrl(process.env.GRANULAR_AUTH_URL);
5466
5518
  }
5467
5519
  function saveApiKey(apiKey) {
5468
5520
  const envLocalPath = getEnvLocalPath();
@@ -5500,7 +5552,105 @@ function ensureGitignore() {
5500
5552
  fs__namespace.writeFileSync(gitignorePath, content + section, "utf-8");
5501
5553
  }
5502
5554
  }
5555
+ function createDefaultEffectOperations(classNames) {
5556
+ const operations = classNames.flatMap((className) => {
5557
+ const searchName = `search_${className}s`;
5558
+ return [
5559
+ {
5560
+ withEffect: {
5561
+ name: "get_info",
5562
+ description: `Get details of a ${className} by ID`,
5563
+ attachedClass: className,
5564
+ isStatic: false,
5565
+ inputSchema: {
5566
+ type: "object",
5567
+ properties: {
5568
+ include_related: { type: "boolean", description: "Include related items" }
5569
+ }
5570
+ },
5571
+ outputSchema: {
5572
+ type: "object",
5573
+ properties: {
5574
+ id: { type: "string" },
5575
+ name: { type: "string" },
5576
+ related: { type: "array", items: { type: "object" } }
5577
+ }
5578
+ }
5579
+ }
5580
+ },
5581
+ {
5582
+ withEffect: {
5583
+ name: searchName,
5584
+ description: `Search ${className}s by keyword`,
5585
+ attachedClass: className,
5586
+ isStatic: true,
5587
+ inputSchema: {
5588
+ type: "object",
5589
+ properties: {
5590
+ query: { type: "string", description: "Search keyword" },
5591
+ limit: { type: "number", description: "Max results" }
5592
+ },
5593
+ required: ["query"]
5594
+ },
5595
+ outputSchema: {
5596
+ type: "object",
5597
+ properties: {
5598
+ query: { type: "string" },
5599
+ results: {
5600
+ type: "array",
5601
+ items: {
5602
+ type: "object",
5603
+ properties: {
5604
+ id: { type: "string" },
5605
+ name: { type: "string" }
5606
+ }
5607
+ }
5608
+ },
5609
+ total: { type: "number" }
5610
+ }
5611
+ }
5612
+ }
5613
+ }
5614
+ ];
5615
+ });
5616
+ operations.push({
5617
+ withEffect: {
5618
+ name: "full_text_search",
5619
+ description: "Search across all types",
5620
+ inputSchema: {
5621
+ type: "object",
5622
+ properties: {
5623
+ query: { type: "string", description: "Search query" },
5624
+ types: { type: "array", items: { type: "string" }, description: "Filter by type" }
5625
+ },
5626
+ required: ["query"]
5627
+ },
5628
+ outputSchema: {
5629
+ type: "object",
5630
+ properties: {
5631
+ query: { type: "string" },
5632
+ types: { type: "array", items: { type: "string" } },
5633
+ matches: {
5634
+ type: "array",
5635
+ items: {
5636
+ type: "object",
5637
+ properties: {
5638
+ type: { type: "string" },
5639
+ id: { type: "string" },
5640
+ label: { type: "string" },
5641
+ snippet: { type: "string" }
5642
+ }
5643
+ }
5644
+ },
5645
+ total: { type: "number" }
5646
+ }
5647
+ }
5648
+ }
5649
+ });
5650
+ return operations;
5651
+ }
5503
5652
  function createDefaultManifest(name) {
5653
+ const classNames = ["note", "tag"];
5504
5654
  return {
5505
5655
  manifest: {
5506
5656
  schemaVersion: 2,
@@ -5539,7 +5689,8 @@ function createDefaultManifest(name) {
5539
5689
  leftIsMany: true,
5540
5690
  rightIsMany: true
5541
5691
  }
5542
- }
5692
+ },
5693
+ ...createDefaultEffectOperations(classNames)
5543
5694
  ]
5544
5695
  }]
5545
5696
  }
@@ -5710,7 +5861,7 @@ var ApiClient = class {
5710
5861
  return await this.createPermissionProfile(sandboxId, {
5711
5862
  name: "default",
5712
5863
  rules: {
5713
- tools: { allow: ["*"] },
5864
+ effects: { allow: ["*"] },
5714
5865
  resources: { allow: ["*"] }
5715
5866
  }
5716
5867
  });
@@ -7611,37 +7762,12 @@ async function main() {
7611
7762
  apiUrl: process.env.GRANULAR_API_URL ?? API_URL,
7612
7763
  });
7613
7764
 
7614
- const userId = 'effects_user';
7615
- log(\`Recording user \${userId}\`);
7616
- const user = await granular.recordUser({
7617
- userId,
7618
- name: 'Effects Host',
7619
- permissions: ['default'],
7620
- });
7621
-
7622
- log(\`Connecting to sandbox \${SANDBOX_ID}\`);
7623
- const env = await granular.connect({ sandbox: SANDBOX_ID, user, clientId: 'effects-host' });
7624
-
7625
- log('Waiting for graph container to be ready...');
7626
- for (let i = 0; i < 6; i++) {
7627
- await new Promise((r) => setTimeout(r, 5000));
7628
- try {
7629
- const probe = await env.graphql(\`query { model(path: "class") { path } }\`);
7630
- if ((probe as any)?.data?.model?.path) {
7631
- log(\`Graph ready after \${(i + 1) * 5}s\`);
7632
- break;
7633
- }
7634
- } catch {
7635
- log(\`Not ready yet (\${(i + 1) * 5}s)...\`);
7636
- }
7637
- }
7638
-
7639
- log('Publishing effects...');
7765
+ log(\`Registering live effects for sandbox \${SANDBOX_ID}\`);
7640
7766
  ${mockDataBlock}
7641
7767
 
7642
- await env.publishEffects(${effectsArray});
7768
+ await granular.registerEffects(SANDBOX_ID, ${effectsArray});
7643
7769
 
7644
- log('Effects published. Process kept alive for simulator. Press Ctrl+C to exit.');
7770
+ log('Effects registered. Process kept alive for simulator. Press Ctrl+C to exit.');
7645
7771
  await new Promise(() => {});
7646
7772
  }
7647
7773
 
@@ -7806,6 +7932,7 @@ async function initCommand(projectName, options) {
7806
7932
  hint("granular simulate", "opens app.granular.software/simulator for this sandbox");
7807
7933
  console.log();
7808
7934
  }
7935
+ var DEFAULT_LOCAL_API_KEY = "gn_sk_tenant_default_principal_local_e2e_00000000";
7809
7936
  function promptSecret(question) {
7810
7937
  const rl = readline__namespace.createInterface({ input: process.stdin, output: process.stdout });
7811
7938
  return new Promise((resolve) => {
@@ -7831,6 +7958,9 @@ function maskApiKey(apiKey) {
7831
7958
  if (apiKey.length < 10) return `${apiKey}...`;
7832
7959
  return `${apiKey.substring(0, 10)}...`;
7833
7960
  }
7961
+ function isLocalApiUrl(apiUrl) {
7962
+ return apiUrl.startsWith("ws://localhost:") || apiUrl.startsWith("wss://localhost:") || apiUrl.startsWith("ws://127.0.0.1:") || apiUrl.startsWith("wss://127.0.0.1:") || apiUrl.startsWith("http://localhost:") || apiUrl.startsWith("https://localhost:") || apiUrl.startsWith("http://127.0.0.1:") || apiUrl.startsWith("https://127.0.0.1:");
7963
+ }
7834
7964
  async function loginCommand(options = {}) {
7835
7965
  printHeader();
7836
7966
  const existing = loadApiKey();
@@ -7842,7 +7972,10 @@ async function loginCommand(options = {}) {
7842
7972
  const apiUrl = loadApiUrl();
7843
7973
  let apiKey = options.apiKey?.trim();
7844
7974
  if (!apiKey) {
7845
- if (options.manual) {
7975
+ if (!options.manual && isLocalApiUrl(apiUrl)) {
7976
+ apiKey = existing?.trim() || process.env.GRANULAR_LOCAL_API_KEY?.trim() || DEFAULT_LOCAL_API_KEY;
7977
+ info("Using local Granular API key for localhost development.");
7978
+ } else if (options.manual) {
7846
7979
  dim("Get your API key at https://app.granular.software/w/default/api-keys");
7847
7980
  console.log();
7848
7981
  apiKey = await promptSecret("Enter your API key");
@@ -8396,7 +8529,7 @@ ${manifest.description}`);
8396
8529
  lines.push(`- [Integration Guide](#integration-guide)`);
8397
8530
  lines.push(` - [1. Initialize & Connect](#1-initialize--connect)`);
8398
8531
  lines.push(` - [2. Record Objects](#2-record-objects)`);
8399
- lines.push(` - [3. Publish Tools](#3-publish-tools)`);
8532
+ lines.push(` - [3. Declare And Register Effects](#3-declare-and-register-effects)`);
8400
8533
  lines.push(` - [4. Submit Jobs](#4-submit-jobs)`);
8401
8534
  lines.push(`
8402
8535
  ## Getting Started`);
@@ -8512,11 +8645,11 @@ console.log('Connected to:', env.environmentId);`);
8512
8645
  lines.push(`*(No classes defined in manifest)*`);
8513
8646
  }
8514
8647
  lines.push(`
8515
- ### 3. Publish Tools`);
8516
- lines.push(`Define tools that your agent can use properly typed.`);
8648
+ ### 3. Declare And Register Effects`);
8649
+ lines.push(`Declare effects in your manifest with \`withEffect\`, then register live handlers at sandbox scope.`);
8517
8650
  lines.push(`
8518
8651
  \`\`\`typescript`);
8519
- lines.push(`await env.publishTools([`);
8652
+ lines.push(`await granular.registerEffects('your-sandbox-id', [`);
8520
8653
  if (classes.length > 0) {
8521
8654
  const cls = classes[0];
8522
8655
  lines.push(` // Instance method on ${cls.name}`);
@@ -8526,8 +8659,9 @@ console.log('Connected to:', env.environmentId);`);
8526
8659
  lines.push(` className: '${cls.name}',`);
8527
8660
  lines.push(` inputSchema: { type: 'object', properties: { maxLength: { type: 'number' } } },`);
8528
8661
  lines.push(` outputSchema: { type: 'object', properties: { summary: { type: 'string' } }, required: ['summary'] },`);
8529
- lines.push(` handler: async (id, params) => {`);
8662
+ lines.push(` handler: async (id, params, ctx) => {`);
8530
8663
  lines.push(` // 'id' is the real-world ID of the ${cls.name}`);
8664
+ lines.push(` console.log('Invoked for user', ctx.user.subjectId);`);
8531
8665
  lines.push(` return { summary: \`Summary for \${id}\` };`);
8532
8666
  lines.push(` },`);
8533
8667
  lines.push(` },`);
@@ -8539,17 +8673,19 @@ console.log('Connected to:', env.environmentId);`);
8539
8673
  lines.push(` static: true,`);
8540
8674
  lines.push(` inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },`);
8541
8675
  lines.push(` outputSchema: { type: 'object', properties: { ids: { type: 'array' } }, required: ['ids'] },`);
8542
- lines.push(` handler: async (params) => {`);
8676
+ lines.push(` handler: async (params, ctx) => {`);
8677
+ lines.push(` console.log('Invoked for user', ctx.user.subjectId);`);
8543
8678
  lines.push(` return { ids: [] };`);
8544
8679
  lines.push(` },`);
8545
8680
  lines.push(` },`);
8546
8681
  }
8547
- lines.push(` // Global tool`);
8682
+ lines.push(` // Global effect`);
8548
8683
  lines.push(` {`);
8549
8684
  lines.push(` name: 'notify',`);
8550
8685
  lines.push(` description: 'Send notification',`);
8551
8686
  lines.push(` inputSchema: { type: 'object', properties: { msg: { type: 'string' } }, required: ['msg'] },`);
8552
- lines.push(` handler: async (params) => {`);
8687
+ lines.push(` handler: async (params, ctx) => {`);
8688
+ lines.push(` console.log('Invoked for user', ctx.user.subjectId);`);
8553
8689
  lines.push(` console.log('Notification:', params.msg);`);
8554
8690
  lines.push(` },`);
8555
8691
  lines.push(` },`);
@@ -8623,6 +8759,28 @@ async function simulateCommand(sandboxIdArg) {
8623
8759
  var VERSION = "0.2.0";
8624
8760
  var program2 = new Command();
8625
8761
  program2.name("granular").description("Build and deploy AI sandboxes from code").version(VERSION, "-v, --version");
8762
+ program2.option("--local", "Use local endpoints (localhost)").option("--prod", "Use production endpoints").option("--env <target>", "Endpoint target: local|production");
8763
+ program2.hook("preAction", () => {
8764
+ const opts = program2.opts();
8765
+ const normalizedEnv = opts.env?.trim().toLowerCase();
8766
+ if (opts.local && opts.prod) {
8767
+ error("Cannot use --local and --prod at the same time.");
8768
+ process.exit(1);
8769
+ }
8770
+ if (normalizedEnv && !["local", "production", "prod"].includes(normalizedEnv)) {
8771
+ error('Invalid --env value. Use "local" or "production".');
8772
+ process.exit(1);
8773
+ }
8774
+ let mode;
8775
+ if (opts.local || normalizedEnv === "local") {
8776
+ mode = "local";
8777
+ } else if (opts.prod || normalizedEnv === "production" || normalizedEnv === "prod") {
8778
+ mode = "production";
8779
+ }
8780
+ if (mode) {
8781
+ process.env.GRANULAR_ENDPOINT_MODE = mode;
8782
+ }
8783
+ });
8626
8784
  program2.command("init [project-name]").description("Initialize a new Granular project").option("--skip-build", "Skip the initial build step").action(async (projectName, opts) => {
8627
8785
  try {
8628
8786
  await initCommand(projectName, { skipBuild: opts.skipBuild });