@orchagent/cli 0.3.67 → 0.3.69

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.
@@ -8,6 +8,7 @@ const promises_1 = __importDefault(require("fs/promises"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const errors_1 = require("../lib/errors");
10
10
  const github_weekly_summary_1 = require("./templates/github-weekly-summary");
11
+ const support_agent_1 = require("./templates/support-agent");
11
12
  const MANIFEST_TEMPLATE = `{
12
13
  "name": "my-agent",
13
14
  "description": "A simple AI agent",
@@ -87,6 +88,51 @@ if __name__ == "__main__":
87
88
  main()
88
89
  `;
89
90
  function readmeTemplate(agentName, flavor) {
91
+ if (flavor === 'support_agent') {
92
+ return `# ${agentName}
93
+
94
+ A multi-platform support agent powered by Claude. Connects to Discord, Telegram, and/or Slack.
95
+
96
+ ## Setup
97
+
98
+ ### 1. Customize
99
+
100
+ Edit \`config.py\` — set your product name, description, and bot name.
101
+
102
+ ### 2. Add Knowledge
103
+
104
+ Replace the example files in \`knowledge/\` with your own docs.
105
+ Files named \`NN-topic-name.md\` auto-discover as topics.
106
+
107
+ ### 3. Set Platform Tokens
108
+
109
+ Copy \`.env.example\` to \`.env\` and fill in tokens for the platforms you want.
110
+ At least one platform token is required.
111
+
112
+ ### 4. Run Locally
113
+
114
+ \`\`\`sh
115
+ pip install -r requirements.txt
116
+ python main.py
117
+ \`\`\`
118
+
119
+ ### 5. Deploy
120
+
121
+ \`\`\`sh
122
+ orch publish
123
+ # Add secrets in your workspace (web dashboard > Settings > Secrets)
124
+ orch service deploy
125
+ \`\`\`
126
+
127
+ ## Platforms
128
+
129
+ | Platform | Token Required | Extra Config |
130
+ |----------|---------------|--------------|
131
+ | Discord | \`DISCORD_BOT_TOKEN\` | \`DISCORD_CHANNEL_IDS\` |
132
+ | Telegram | \`TELEGRAM_BOT_TOKEN\` | — |
133
+ | Slack | \`SLACK_BOT_TOKEN\` | \`SLACK_APP_TOKEN\` |
134
+ `;
135
+ }
90
136
  if (flavor === 'discord') {
91
137
  return `# ${agentName}
92
138
 
@@ -479,7 +525,7 @@ function registerInitCommand(program) {
479
525
  .option('--type <type>', 'Type: prompt, tool, agent, or skill (legacy aliases: agentic, code)', 'prompt')
480
526
  .option('--orchestrator', 'Create an orchestrator agent with dependency scaffolding and SDK boilerplate')
481
527
  .option('--run-mode <mode>', 'Run mode for agents: on_demand or always_on', 'on_demand')
482
- .option('--template <name>', 'Start from a template (available: github-weekly-summary, discord)')
528
+ .option('--template <name>', 'Start from a template (available: support-agent, discord, github-weekly-summary)')
483
529
  .action(async (name, options) => {
484
530
  const cwd = process.cwd();
485
531
  let runMode = (options.runMode || 'on_demand').trim().toLowerCase();
@@ -495,7 +541,7 @@ function registerInitCommand(program) {
495
541
  }
496
542
  if (options.template) {
497
543
  const template = options.template.trim().toLowerCase();
498
- const validTemplates = ['discord', 'github-weekly-summary'];
544
+ const validTemplates = ['support-agent', 'discord', 'github-weekly-summary'];
499
545
  if (!validTemplates.includes(template)) {
500
546
  throw new errors_1.CliError(`Unknown --template '${template}'. Available templates: ${validTemplates.join(', ')}`);
501
547
  }
@@ -505,7 +551,11 @@ function registerInitCommand(program) {
505
551
  if (initMode.type === 'skill') {
506
552
  throw new errors_1.CliError('Cannot use --template with --type skill.');
507
553
  }
508
- if (template === 'discord') {
554
+ if (template === 'support-agent') {
555
+ initMode = { type: 'agent', flavor: 'support_agent' };
556
+ runMode = 'always_on';
557
+ }
558
+ else if (template === 'discord') {
509
559
  initMode = { type: 'agent', flavor: 'discord' };
510
560
  runMode = 'always_on';
511
561
  }
@@ -550,6 +600,80 @@ function registerInitCommand(program) {
550
600
  }
551
601
  return;
552
602
  }
603
+ // Handle support-agent template separately (multi-file structure)
604
+ if (initMode.flavor === 'support_agent') {
605
+ const manifestPath = path_1.default.join(targetDir, 'orchagent.json');
606
+ try {
607
+ await promises_1.default.access(manifestPath);
608
+ throw new errors_1.CliError(`Already initialized (orchagent.json exists in ${name ? name + '/' : 'current directory'})`);
609
+ }
610
+ catch (err) {
611
+ if (err.code !== 'ENOENT') {
612
+ throw err;
613
+ }
614
+ }
615
+ // Create subdirectories
616
+ await promises_1.default.mkdir(path_1.default.join(targetDir, 'connectors'), { recursive: true });
617
+ await promises_1.default.mkdir(path_1.default.join(targetDir, 'knowledge'), { recursive: true });
618
+ // Write manifest
619
+ const manifest = {
620
+ name: agentName,
621
+ type: 'agent',
622
+ description: 'Multi-platform support agent powered by Claude. Connects to Discord, Telegram, and/or Slack.',
623
+ run_mode: 'always_on',
624
+ runtime: { command: 'python main.py' },
625
+ entrypoint: 'main.py',
626
+ supported_providers: ['anthropic'],
627
+ default_models: { anthropic: 'claude-sonnet-4-5-20250929' },
628
+ required_secrets: [],
629
+ optional_secrets: ['DISCORD_BOT_TOKEN', 'DISCORD_CHANNEL_IDS', 'TELEGRAM_BOT_TOKEN', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
630
+ tags: ['support', 'discord', 'telegram', 'slack', 'always-on', 'multi-platform'],
631
+ bundle: {
632
+ include: ['*.py', 'connectors/*.py', 'knowledge/*.md', 'requirements.txt'],
633
+ exclude: ['tests/', '__pycache__', '*.pyc', '.pytest_cache'],
634
+ },
635
+ };
636
+ await promises_1.default.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
637
+ // Write all source files
638
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'config.py'), support_agent_1.SA_CONFIG_PY);
639
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'brain.py'), support_agent_1.SA_BRAIN_PY);
640
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'main.py'), support_agent_1.SA_MAIN_PY);
641
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'connectors', '__init__.py'), '');
642
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'connectors', 'discord_connector.py'), support_agent_1.SA_DISCORD_CONNECTOR_PY);
643
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'connectors', 'telegram_connector.py'), support_agent_1.SA_TELEGRAM_CONNECTOR_PY);
644
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'connectors', 'slack_connector.py'), support_agent_1.SA_SLACK_CONNECTOR_PY);
645
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'requirements.txt'), support_agent_1.SA_REQUIREMENTS);
646
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'knowledge', '00-overview.md'), support_agent_1.SA_OVERVIEW_MD);
647
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'knowledge', '99-faq.md'), support_agent_1.SA_FAQ_MD);
648
+ await promises_1.default.writeFile(path_1.default.join(targetDir, '.env.example'), support_agent_1.SA_ENV_EXAMPLE);
649
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'README.md'), readmeTemplate(agentName, 'support_agent'));
650
+ const prefix = name ? name + '/' : '';
651
+ process.stdout.write(`\nInitialized support-agent "${agentName}" in ${targetDir}\n`);
652
+ process.stdout.write(`\nFiles created:\n`);
653
+ process.stdout.write(` ${prefix}orchagent.json Agent manifest\n`);
654
+ process.stdout.write(` ${prefix}config.py Customize here (4 fields)\n`);
655
+ process.stdout.write(` ${prefix}brain.py Three-tier classifier + responder\n`);
656
+ process.stdout.write(` ${prefix}main.py Startup orchestrator\n`);
657
+ process.stdout.write(` ${prefix}connectors/discord_connector.py Discord connector\n`);
658
+ process.stdout.write(` ${prefix}connectors/telegram_connector.py Telegram connector\n`);
659
+ process.stdout.write(` ${prefix}connectors/slack_connector.py Slack connector\n`);
660
+ process.stdout.write(` ${prefix}knowledge/00-overview.md Example FAQ knowledge\n`);
661
+ process.stdout.write(` ${prefix}knowledge/99-faq.md Example FAQ knowledge\n`);
662
+ process.stdout.write(` ${prefix}requirements.txt Python dependencies\n`);
663
+ process.stdout.write(` ${prefix}.env.example Environment variables\n`);
664
+ process.stdout.write(` ${prefix}README.md Setup guide\n`);
665
+ process.stdout.write(`\nNext steps:\n`);
666
+ const s = name ? 2 : 1;
667
+ if (name) {
668
+ process.stdout.write(` 1. cd ${name}\n`);
669
+ }
670
+ process.stdout.write(` ${s}. Edit config.py with your product name and description\n`);
671
+ process.stdout.write(` ${s + 1}. Replace knowledge/ files with your own docs\n`);
672
+ process.stdout.write(` ${s + 2}. Copy .env.example to .env and add platform tokens\n`);
673
+ process.stdout.write(` ${s + 3}. Test locally: pip install -r requirements.txt && python main.py\n`);
674
+ process.stdout.write(` ${s + 4}. Deploy: orch publish && orch service deploy\n`);
675
+ return;
676
+ }
553
677
  // Handle github-weekly-summary template separately (own file set + output)
554
678
  if (initMode.flavor === 'github_weekly_summary') {
555
679
  const manifestPath = path_1.default.join(targetDir, 'orchagent.json');
@@ -901,7 +901,7 @@ function registerPublishCommand(program) {
901
901
  }
902
902
  // Upload the bundle with entrypoint
903
903
  process.stdout.write(` Uploading bundle...\n`);
904
- const uploadResult = await (0, api_1.uploadCodeBundle)(config, agentId, bundlePath, bundleEntrypoint);
904
+ const uploadResult = await (0, api_1.uploadCodeBundle)(config, agentId, bundlePath, bundleEntrypoint, workspaceId);
905
905
  process.stdout.write(` Uploaded: ${uploadResult.code_hash.substring(0, 12)}...\n`);
906
906
  // Show environment info if applicable
907
907
  if (uploadResult.environment_id) {
@@ -407,7 +407,7 @@ async function buildInjectedPayload(options) {
407
407
  return { body: JSON.stringify(merged), sourceLabel };
408
408
  }
409
409
  // ─── Local execution helpers ────────────────────────────────────────────────
410
- async function downloadAgent(config, org, agent, version) {
410
+ async function downloadAgent(config, org, agent, version, workspaceId) {
411
411
  // Try public endpoint first
412
412
  try {
413
413
  return await (0, api_1.publicRequest)(config, `/public/agents/${org}/${agent}/${version}/download`);
@@ -421,7 +421,7 @@ async function downloadAgent(config, org, agent, version) {
421
421
  // Try owner path if authenticated
422
422
  if (config.apiKey) {
423
423
  try {
424
- const myAgents = await (0, api_1.listMyAgents)(config);
424
+ const myAgents = await (0, api_1.listMyAgents)(config, workspaceId);
425
425
  const matchingAgent = myAgents.find(a => a.name === agent && a.version === version && a.org_slug === org);
426
426
  if (matchingAgent) {
427
427
  // Owner! Fetch from authenticated endpoint
@@ -470,12 +470,12 @@ async function downloadAgent(config, org, agent, version) {
470
470
  if (!config.apiKey) {
471
471
  throw new api_1.ApiError(`Agent '${org}/${agent}@${version}' not found`, 404);
472
472
  }
473
- const userOrg = await (0, api_1.getOrg)(config);
473
+ const userOrg = await (0, api_1.getOrg)(config, workspaceId);
474
474
  if (userOrg.slug !== org) {
475
475
  throw new api_1.ApiError(`Agent '${org}/${agent}@${version}' not found`, 404);
476
476
  }
477
477
  // Find agent in user's list
478
- const agents = await (0, api_1.listMyAgents)(config);
478
+ const agents = await (0, api_1.listMyAgents)(config, workspaceId);
479
479
  const matching = agents.filter(a => a.name === agent);
480
480
  if (matching.length === 0) {
481
481
  throw new api_1.ApiError(`Agent '${org}/${agent}@${version}' not found`, 404);
@@ -1616,7 +1616,9 @@ async function executeCloud(agentRef, file, options) {
1616
1616
  if (!org) {
1617
1617
  throw new errors_1.CliError('Missing org. Use org/agent or set default org.');
1618
1618
  }
1619
- const agentMeta = await (0, api_1.getAgentWithFallback)(resolved, org, parsed.agent, parsed.version);
1619
+ // Resolve workspace context for the target org
1620
+ const workspaceId = await (0, api_1.resolveWorkspaceIdForOrg)(resolved, org);
1621
+ const agentMeta = await (0, api_1.getAgentWithFallback)(resolved, org, parsed.agent, parsed.version, workspaceId);
1620
1622
  const cloudType = canonicalAgentType(agentMeta.type);
1621
1623
  const cloudEngine = resolveExecutionEngine({
1622
1624
  type: agentMeta.type,
@@ -1630,22 +1632,19 @@ async function executeCloud(agentRef, file, options) {
1630
1632
  const agentRequiredSecrets = agentMeta.required_secrets;
1631
1633
  if (agentRequiredSecrets?.length) {
1632
1634
  try {
1633
- const wsSlug = configFile.workspace;
1634
- if (wsSlug) {
1635
- const { workspaces } = await (0, api_1.request)(resolved, 'GET', '/workspaces');
1636
- const ws = workspaces.find((w) => w.slug === wsSlug);
1637
- if (ws) {
1638
- const secretsResult = await (0, api_1.request)(resolved, 'GET', `/workspaces/${ws.id}/secrets`);
1639
- const existingNames = new Set(secretsResult.secrets.map((s) => s.name));
1640
- const missing = agentRequiredSecrets.filter((s) => !existingNames.has(s));
1641
- if (missing.length > 0) {
1642
- throw new errors_1.CliError(`Agent requires secrets not found in workspace '${wsSlug}':\n` +
1643
- missing.map((s) => ` - ${s}`).join('\n') + '\n\n' +
1644
- `Set them before running:\n` +
1645
- missing.map((s) => ` orch secrets set ${s} <value>`).join('\n') + '\n\n' +
1646
- `Secrets are injected as environment variables into the agent sandbox.\n` +
1647
- `View existing secrets: orch secrets list`);
1648
- }
1635
+ // Use already-resolved workspaceId (or fall back to config workspace slug)
1636
+ const wsId = workspaceId ?? (configFile.workspace ? (await (0, api_1.resolveWorkspaceIdForOrg)(resolved, configFile.workspace)) : undefined);
1637
+ if (wsId) {
1638
+ const secretsResult = await (0, api_1.request)(resolved, 'GET', `/workspaces/${wsId}/secrets`);
1639
+ const existingNames = new Set(secretsResult.secrets.map((s) => s.name));
1640
+ const missing = agentRequiredSecrets.filter((s) => !existingNames.has(s));
1641
+ if (missing.length > 0) {
1642
+ throw new errors_1.CliError(`Agent requires secrets not found in workspace '${org}':\n` +
1643
+ missing.map((s) => ` - ${s}`).join('\n') + '\n\n' +
1644
+ `Set them before running:\n` +
1645
+ missing.map((s) => ` orch secrets set ${s} <value>`).join('\n') + '\n\n' +
1646
+ `Secrets are injected as environment variables into the agent sandbox.\n` +
1647
+ `View existing secrets: orch secrets list`);
1649
1648
  }
1650
1649
  }
1651
1650
  }
@@ -1661,7 +1660,7 @@ async function executeCloud(agentRef, file, options) {
1661
1660
  if ((0, pricing_1.isPaidAgent)(agentMeta)) {
1662
1661
  let isOwner = false;
1663
1662
  try {
1664
- const callerOrg = await (0, api_1.getOrg)(resolved);
1663
+ const callerOrg = await (0, api_1.getOrg)(resolved, workspaceId);
1665
1664
  const agentOrgId = agentMeta.org_id;
1666
1665
  const agentOrgSlug = agentMeta.org_slug;
1667
1666
  if (agentOrgId && callerOrg.id === agentOrgId) {
@@ -1714,6 +1713,9 @@ async function executeCloud(agentRef, file, options) {
1714
1713
  'X-CLI-Version': package_json_1.default.version,
1715
1714
  'X-OrchAgent-Client': 'cli',
1716
1715
  };
1716
+ if (workspaceId) {
1717
+ headers['X-Workspace-Id'] = workspaceId;
1718
+ }
1717
1719
  if (options.tenant) {
1718
1720
  headers['X-OrchAgent-Tenant'] = options.tenant;
1719
1721
  }
@@ -2299,10 +2301,12 @@ async function executeLocal(agentRef, args, options) {
2299
2301
  if (!org) {
2300
2302
  throw new errors_1.CliError('Missing org. Use org/agent format.');
2301
2303
  }
2304
+ // Resolve workspace context for the target org
2305
+ const workspaceId = await (0, api_1.resolveWorkspaceIdForOrg)(resolved, org);
2302
2306
  // Download agent definition with spinner
2303
2307
  const agentData = await (0, spinner_1.withSpinner)(`Downloading ${org}/${parsed.agent}@${parsed.version}...`, async () => {
2304
2308
  try {
2305
- return await downloadAgent(resolved, org, parsed.agent, parsed.version);
2309
+ return await downloadAgent(resolved, org, parsed.agent, parsed.version, workspaceId);
2306
2310
  }
2307
2311
  catch (err) {
2308
2312
  const agentMeta = await (0, api_1.getPublicAgent)(resolved, org, parsed.agent, parsed.version);
@@ -143,8 +143,8 @@ function registerScheduleCommand(program) {
143
143
  }
144
144
  const workspaceId = await resolveWorkspaceId(config, options.workspace);
145
145
  const ref = (0, agent_ref_1.parseAgentRef)(agentArg);
146
- // Resolve agent to get the ID
147
- const agent = await (0, api_2.getAgentWithFallback)(config, ref.org, ref.agent, ref.version);
146
+ // Resolve agent to get the ID (pass workspace context for private agents)
147
+ const agent = await (0, api_2.getAgentWithFallback)(config, ref.org, ref.agent, ref.version, workspaceId);
148
148
  // Parse input data
149
149
  let inputData;
150
150
  if (options.input) {
@@ -93,9 +93,10 @@ function registerServiceCommand(program) {
93
93
  .option('--command <cmd>', 'Override entrypoint command')
94
94
  .option('--arg <value>', 'Command argument (repeatable)', collectArray, [])
95
95
  .option('--pin', 'Pin to deployed version (disable auto-update on publish)')
96
+ .option('--profile <name>', 'Use API key from named profile')
96
97
  .option('--json', 'Output as JSON')
97
98
  .action(async (agentArg, options) => {
98
- const config = await (0, config_1.getResolvedConfig)();
99
+ const config = await (0, config_1.getResolvedConfig)({}, options.profile);
99
100
  if (!config.apiKey) {
100
101
  throw new errors_1.CliError('Missing API key. Run `orch login` first.');
101
102
  }
@@ -0,0 +1,570 @@
1
+ "use strict";
2
+ /**
3
+ * Multi-Platform Support Agent template.
4
+ *
5
+ * Generates a three-tier support agent that connects to Discord, Telegram,
6
+ * and/or Slack based on which tokens are configured.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.SA_ENV_EXAMPLE = exports.SA_FAQ_MD = exports.SA_OVERVIEW_MD = exports.SA_REQUIREMENTS = exports.SA_MAIN_PY = exports.SA_SLACK_CONNECTOR_PY = exports.SA_TELEGRAM_CONNECTOR_PY = exports.SA_DISCORD_CONNECTOR_PY = exports.SA_BRAIN_PY = exports.SA_CONFIG_PY = void 0;
10
+ exports.SA_CONFIG_PY = `"""
11
+ Support Agent Configuration — Edit these 4 fields to customize your agent.
12
+ """
13
+
14
+ # The name your bot uses when responding
15
+ BOT_NAME = "Support Agent"
16
+
17
+ # Your product/service name — used in prompts and classification
18
+ PRODUCT_NAME = "My Product"
19
+
20
+ # One-sentence description — helps the classifier understand your domain
21
+ PRODUCT_DESCRIPTION = "a platform for doing amazing things"
22
+
23
+ # Knowledge files used for the FAQ tier (general questions).
24
+ FAQ_FILES = ["00-overview.md", "99-faq.md"]
25
+ `;
26
+ exports.SA_BRAIN_PY = `"""
27
+ Support Agent Brain — Three-Tier Architecture
28
+
29
+ Tier 1: CLASSIFY — Haiku classifier decides if/how to respond
30
+ Tier 2: FAQ — General knowledge handles ~80% of questions (cached)
31
+ Tier 3: DEEP DOCS — Topic-specific docs for detailed questions (cached per topic)
32
+ """
33
+
34
+ import json
35
+ import logging
36
+ import os
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ import anthropic
41
+
42
+ from config import BOT_NAME, FAQ_FILES, PRODUCT_DESCRIPTION, PRODUCT_NAME
43
+
44
+ logger = logging.getLogger("support-agent.brain")
45
+
46
+ CLASSIFY_MODEL = os.environ.get("CLASSIFY_MODEL", "claude-haiku-4-5-20251001")
47
+ RESPOND_MODEL = os.environ.get("RESPOND_MODEL", "claude-sonnet-4-5-20250929")
48
+ CLASSIFY_MAX_TOKENS = 150
49
+ RESPOND_MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "1024"))
50
+
51
+ DOCS_DIR = Path(__file__).parent / "knowledge"
52
+
53
+
54
+ def load_doc_files() -> dict[str, str]:
55
+ docs: dict[str, str] = {}
56
+ if not DOCS_DIR.is_dir():
57
+ logger.warning("knowledge/ directory not found")
58
+ return docs
59
+ for md_file in sorted(DOCS_DIR.glob("*.md")):
60
+ content = md_file.read_text(encoding="utf-8").strip()
61
+ if content:
62
+ docs[md_file.name] = content
63
+ logger.info("Loaded %d knowledge files", len(docs))
64
+ return docs
65
+
66
+
67
+ def discover_topics(doc_files: dict[str, str]) -> dict[str, list[str]]:
68
+ topics: dict[str, list[str]] = {}
69
+ faq_set = set(FAQ_FILES)
70
+ for filename in sorted(doc_files.keys()):
71
+ if filename in faq_set:
72
+ continue
73
+ stem = filename.rsplit(".", 1)[0]
74
+ parts = stem.split("-", 1)
75
+ if len(parts) == 2 and parts[0].isdigit():
76
+ topic = parts[1]
77
+ else:
78
+ topic = stem
79
+ topic = topic.strip().lower().replace(" ", "-").replace("_", "-")
80
+ if not topic:
81
+ continue
82
+ topics.setdefault(topic, []).append(filename)
83
+ return topics
84
+
85
+
86
+ def build_docs_context(doc_files: dict[str, str], filenames: list[str]) -> str:
87
+ parts: list[str] = []
88
+ for name in filenames:
89
+ if name in doc_files:
90
+ title = name.rsplit(".", 1)[0].replace("-", " ").title()
91
+ parts.append(f"## {title}\\n\\n{doc_files[name]}")
92
+ return "\\n\\n---\\n\\n".join(parts)
93
+
94
+
95
+ def get_deep_files(topics: list[str], topic_files: dict[str, list[str]]) -> list[str]:
96
+ files: list[str] = []
97
+ seen: set[str] = set()
98
+ for topic in topics:
99
+ for f in topic_files.get(topic, []):
100
+ if f not in seen:
101
+ files.append(f)
102
+ seen.add(f)
103
+ for f in FAQ_FILES:
104
+ if f not in seen:
105
+ files.append(f)
106
+ seen.add(f)
107
+ return files
108
+
109
+
110
+ def build_response_rules() -> str:
111
+ return f"""\\
112
+ You are {BOT_NAME}, the official support assistant for {PRODUCT_NAME}.
113
+
114
+ Your job is to answer user questions about {PRODUCT_NAME} accurately and helpfully, grounded in the documentation provided below. Follow these rules:
115
+
116
+ 1. Base your answers ONLY on the documentation below. Do not guess or make up features.
117
+ 2. Be concise and direct. Messages should be readable, not walls of text.
118
+ 3. Use code blocks for commands and code examples.
119
+ 4. If the documentation doesn't cover the question, say: "I'm not sure about this — a human will follow up." Do NOT hallucinate an answer.
120
+ 5. If a question is ambiguous, ask for clarification before answering.
121
+ 6. Be friendly and professional. You represent the {PRODUCT_NAME} team.
122
+ 7. Do not discuss pricing specifics beyond what's in the docs.
123
+ 8. Do not share internal implementation details, architecture decisions, or roadmap items.
124
+ 9. Keep responses under 1800 characters to stay readable.
125
+
126
+ ---
127
+
128
+ # {PRODUCT_NAME} Documentation
129
+
130
+ """
131
+
132
+
133
+ def build_classify_system_prompt(topic_list: list[str]) -> str:
134
+ topic_csv = ", ".join(topic_list) if topic_list else "general"
135
+ return f"""\\
136
+ You are a message classifier for the {PRODUCT_NAME} support channel.
137
+ {PRODUCT_NAME} is {PRODUCT_DESCRIPTION}.
138
+
139
+ Classify the user's message. Return ONLY valid JSON, no other text.
140
+
141
+ Topics: {topic_csv}
142
+
143
+ {{"respond": bool, "tier": "faq" or "deep", "topics": ["topic1"]}}
144
+
145
+ Rules:
146
+ - "respond": false for greetings, thanks, off-topic chat, non-questions, emoji, memes
147
+ - "tier": "faq" for simple/general questions about the platform
148
+ - "tier": "deep" for questions needing specific technical details
149
+ - "topics": 1-2 relevant topics when tier is "deep", empty list for "faq" """
150
+
151
+
152
+ def build_faq_prompt(doc_files: dict[str, str]) -> str:
153
+ return build_response_rules() + build_docs_context(doc_files, FAQ_FILES)
154
+
155
+
156
+ def build_deep_prompt(doc_files: dict[str, str], topics: list[str], topic_files: dict[str, list[str]]) -> str:
157
+ files = get_deep_files(topics, topic_files)
158
+ return build_response_rules() + build_docs_context(doc_files, files)
159
+
160
+
161
+ def create_anthropic_client() -> anthropic.Anthropic:
162
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
163
+ if not api_key:
164
+ logger.error("ANTHROPIC_API_KEY not set")
165
+ sys.exit(1)
166
+ return anthropic.Anthropic(api_key=api_key)
167
+
168
+
169
+ def classify_message(client: anthropic.Anthropic, user_message: str, system_prompt: str) -> dict:
170
+ try:
171
+ response = client.messages.create(
172
+ model=CLASSIFY_MODEL, max_tokens=CLASSIFY_MAX_TOKENS,
173
+ system=system_prompt, messages=[{"role": "user", "content": user_message}],
174
+ )
175
+ raw = response.content[0].text.strip()
176
+ if raw.startswith("\\\`\\\`\\\`"):
177
+ raw = raw.split("\\n", 1)[1].rsplit("\\\`\\\`\\\`", 1)[0].strip()
178
+ result = json.loads(raw)
179
+ logger.info("Classify: respond=%s tier=%s topics=%s (in=%d out=%d)",
180
+ result.get("respond"), result.get("tier"), result.get("topics"),
181
+ response.usage.input_tokens, response.usage.output_tokens)
182
+ return result
183
+ except (json.JSONDecodeError, KeyError, IndexError) as exc:
184
+ logger.warning("Classifier returned invalid JSON: %s", exc)
185
+ return {"respond": True, "tier": "faq", "topics": []}
186
+ except anthropic.APIError as exc:
187
+ logger.warning("Classifier API error: %s", exc)
188
+ return {"respond": True, "tier": "faq", "topics": []}
189
+
190
+
191
+ def query_llm(client: anthropic.Anthropic, system_prompt: str, user_message: str) -> tuple[str, int, int]:
192
+ response = client.messages.create(
193
+ model=RESPOND_MODEL, max_tokens=RESPOND_MAX_TOKENS,
194
+ system=[{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}],
195
+ messages=[{"role": "user", "content": user_message}],
196
+ )
197
+ return response.content[0].text, response.usage.input_tokens, response.usage.output_tokens
198
+
199
+
200
+ class Brain:
201
+ def __init__(self, anthropic_client, doc_files, topic_files, faq_prompt, classify_system_prompt):
202
+ self.client = anthropic_client
203
+ self.doc_files = doc_files
204
+ self.topic_files = topic_files
205
+ self.faq_prompt = faq_prompt
206
+ self.classify_system_prompt = classify_system_prompt
207
+
208
+ def classify(self, message: str) -> dict:
209
+ return classify_message(self.client, message, self.classify_system_prompt)
210
+
211
+ def respond_faq(self, message: str) -> tuple[str, int, int]:
212
+ return query_llm(self.client, self.faq_prompt, message)
213
+
214
+ def respond_deep(self, message: str, topics: list[str]) -> tuple[str, int, int]:
215
+ prompt = build_deep_prompt(self.doc_files, topics, self.topic_files)
216
+ return query_llm(self.client, prompt, message)
217
+ `;
218
+ exports.SA_DISCORD_CONNECTOR_PY = `"""Discord connector for the support agent."""
219
+
220
+ import asyncio
221
+ import logging
222
+ import os
223
+ import time
224
+
225
+ import anthropic
226
+ import discord
227
+
228
+ from brain import Brain
229
+
230
+ logger = logging.getLogger("support-agent.discord")
231
+ USER_COOLDOWN_SECONDS = int(os.environ.get("USER_COOLDOWN_SECONDS", "5"))
232
+
233
+
234
+ def parse_channel_ids(raw: str) -> set[int]:
235
+ ids: set[int] = set()
236
+ for part in raw.split(","):
237
+ part = part.strip()
238
+ if part.isdigit():
239
+ ids.add(int(part))
240
+ return ids
241
+
242
+
243
+ def should_respond(message: discord.Message, allowed_channels: set[int]) -> bool:
244
+ if message.author.bot:
245
+ return False
246
+ channel_id = message.channel.id
247
+ parent_id = getattr(message.channel, "parent_id", None)
248
+ if channel_id not in allowed_channels and parent_id not in allowed_channels:
249
+ return False
250
+ if not message.content or not message.content.strip():
251
+ return False
252
+ if len(message.content.strip()) < 5:
253
+ return False
254
+ return True
255
+
256
+
257
+ class DiscordConnector(discord.Client):
258
+ def __init__(self, brain: Brain, allowed_channels: set[int]):
259
+ intents = discord.Intents.default()
260
+ intents.message_content = True
261
+ super().__init__(intents=intents)
262
+ self.brain = brain
263
+ self.allowed_channels = allowed_channels
264
+ self._user_last_response: dict[int, float] = {}
265
+
266
+ async def on_ready(self) -> None:
267
+ logger.info("Discord connected as %s (id=%s)", self.user, self.user.id)
268
+
269
+ async def on_message(self, message: discord.Message) -> None:
270
+ if not should_respond(message, self.allowed_channels):
271
+ return
272
+ now = time.time()
273
+ if now - self._user_last_response.get(message.author.id, 0) < USER_COOLDOWN_SECONDS:
274
+ return
275
+ user_text = message.content.strip()
276
+ classification = await asyncio.to_thread(self.brain.classify, user_text)
277
+ if not classification.get("respond", True):
278
+ return
279
+ tier = classification.get("tier", "faq")
280
+ topics = classification.get("topics", [])
281
+ try:
282
+ thread = await message.create_thread(name="Q: " + user_text[:80], auto_archive_duration=60)
283
+ except discord.HTTPException:
284
+ thread = message.channel
285
+ async with thread.typing():
286
+ try:
287
+ if tier == "deep" and topics:
288
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_deep, user_text, topics)
289
+ else:
290
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_faq, user_text)
291
+ except anthropic.RateLimitError:
292
+ await thread.send("I'm getting a lot of questions right now. Please try again in a minute.")
293
+ return
294
+ except anthropic.APITimeoutError:
295
+ await thread.send("My response timed out. Please try again.")
296
+ return
297
+ except anthropic.APIError:
298
+ await thread.send("I ran into an issue generating a response. A human will follow up.")
299
+ return
300
+ if len(answer) > 1900:
301
+ answer = answer[:1897] + "..."
302
+ await thread.send(answer)
303
+ self._user_last_response[message.author.id] = time.time()
304
+
305
+ async def run_async(self, token: str) -> None:
306
+ await self.start(token)
307
+ `;
308
+ exports.SA_TELEGRAM_CONNECTOR_PY = `"""Telegram connector for the support agent."""
309
+
310
+ import asyncio
311
+ import logging
312
+ import os
313
+ import time
314
+
315
+ from telegram import Update
316
+ from telegram.ext import Application, ContextTypes, MessageHandler, filters
317
+
318
+ from brain import Brain
319
+
320
+ logger = logging.getLogger("support-agent.telegram")
321
+ USER_COOLDOWN_SECONDS = int(os.environ.get("USER_COOLDOWN_SECONDS", "5"))
322
+
323
+
324
+ class TelegramConnector:
325
+ def __init__(self, brain: Brain, token: str):
326
+ self.brain = brain
327
+ self.token = token
328
+ self._user_last_response: dict[int, float] = {}
329
+
330
+ async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
331
+ message = update.message
332
+ if not message or not message.text:
333
+ return
334
+ user_text = message.text.strip()
335
+ if len(user_text) < 5:
336
+ return
337
+ user_id = message.from_user.id if message.from_user else 0
338
+ now = time.time()
339
+ if now - self._user_last_response.get(user_id, 0) < USER_COOLDOWN_SECONDS:
340
+ return
341
+ classification = await asyncio.to_thread(self.brain.classify, user_text)
342
+ if not classification.get("respond", True):
343
+ return
344
+ tier = classification.get("tier", "faq")
345
+ topics = classification.get("topics", [])
346
+ await message.chat.send_action("typing")
347
+ try:
348
+ if tier == "deep" and topics:
349
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_deep, user_text, topics)
350
+ else:
351
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_faq, user_text)
352
+ except Exception:
353
+ await message.reply_text("I ran into an issue generating a response. A human will follow up.")
354
+ return
355
+ if len(answer) > 4000:
356
+ answer = answer[:3997] + "..."
357
+ await message.reply_text(answer)
358
+ self._user_last_response[user_id] = time.time()
359
+
360
+ async def run(self) -> None:
361
+ app = Application.builder().token(self.token).build()
362
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
363
+ await app.initialize()
364
+ await app.start()
365
+ await app.updater.start_polling(drop_pending_updates=True)
366
+ try:
367
+ await asyncio.Event().wait()
368
+ finally:
369
+ await app.updater.stop()
370
+ await app.stop()
371
+ await app.shutdown()
372
+ `;
373
+ exports.SA_SLACK_CONNECTOR_PY = `"""Slack connector for the support agent (Socket Mode)."""
374
+
375
+ import asyncio
376
+ import logging
377
+ import os
378
+ import time
379
+
380
+ from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
381
+ from slack_bolt.async_app import AsyncApp
382
+
383
+ from brain import Brain
384
+
385
+ logger = logging.getLogger("support-agent.slack")
386
+ USER_COOLDOWN_SECONDS = int(os.environ.get("USER_COOLDOWN_SECONDS", "5"))
387
+
388
+
389
+ class SlackConnector:
390
+ def __init__(self, brain: Brain, bot_token: str, app_token: str):
391
+ self.brain = brain
392
+ self.app = AsyncApp(token=bot_token)
393
+ self.app_token = app_token
394
+ self._user_last_response: dict[str, float] = {}
395
+ self._register_handlers()
396
+
397
+ def _register_handlers(self) -> None:
398
+ @self.app.event("message")
399
+ async def handle_message(event, say):
400
+ if event.get("bot_id") or event.get("subtype"):
401
+ return
402
+ text = event.get("text", "").strip()
403
+ if not text or len(text) < 5:
404
+ return
405
+ user_id = event.get("user", "unknown")
406
+ now = time.time()
407
+ if now - self._user_last_response.get(user_id, 0) < USER_COOLDOWN_SECONDS:
408
+ return
409
+ classification = await asyncio.to_thread(self.brain.classify, text)
410
+ if not classification.get("respond", True):
411
+ return
412
+ tier = classification.get("tier", "faq")
413
+ topics = classification.get("topics", [])
414
+ try:
415
+ if tier == "deep" and topics:
416
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_deep, text, topics)
417
+ else:
418
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_faq, text)
419
+ except Exception:
420
+ await say("I ran into an issue generating a response. A human will follow up.")
421
+ return
422
+ if len(answer) > 3000:
423
+ answer = answer[:2997] + "..."
424
+ await say(answer, thread_ts=event.get("ts"))
425
+ self._user_last_response[user_id] = time.time()
426
+
427
+ async def run(self) -> None:
428
+ handler = AsyncSocketModeHandler(self.app, self.app_token)
429
+ await handler.start_async()
430
+ `;
431
+ exports.SA_MAIN_PY = `"""
432
+ Multi-Platform Support Agent — Startup Orchestrator
433
+
434
+ Checks which platform tokens are configured and starts those connectors.
435
+ """
436
+
437
+ import asyncio
438
+ import logging
439
+ import os
440
+ import sys
441
+
442
+ from brain import (Brain, build_classify_system_prompt, build_faq_prompt,
443
+ create_anthropic_client, discover_topics, load_doc_files)
444
+ from config import FAQ_FILES
445
+
446
+ LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
447
+ logging.basicConfig(level=getattr(logging, LOG_LEVEL, logging.INFO),
448
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s", stream=sys.stdout)
449
+ logger = logging.getLogger("support-agent")
450
+
451
+
452
+ def validate_knowledge(doc_files: dict[str, str]) -> None:
453
+ if not doc_files:
454
+ logger.error("No knowledge files found in knowledge/ directory")
455
+ sys.exit(1)
456
+ for faq_file in FAQ_FILES:
457
+ if faq_file not in doc_files:
458
+ logger.error("FAQ file '%s' not found in knowledge/", faq_file)
459
+ sys.exit(1)
460
+
461
+
462
+ async def run_connectors(brain: Brain) -> None:
463
+ tasks = []
464
+ active = []
465
+
466
+ discord_token = os.environ.get("DISCORD_BOT_TOKEN", "")
467
+ if discord_token:
468
+ from connectors.discord_connector import DiscordConnector, parse_channel_ids
469
+ channels_raw = os.environ.get("DISCORD_CHANNEL_IDS", "")
470
+ if not channels_raw:
471
+ logger.error("DISCORD_BOT_TOKEN set but DISCORD_CHANNEL_IDS missing")
472
+ sys.exit(1)
473
+ channels = parse_channel_ids(channels_raw)
474
+ if not channels:
475
+ logger.error("No valid channel IDs in DISCORD_CHANNEL_IDS")
476
+ sys.exit(1)
477
+ tasks.append(DiscordConnector(brain, channels).run_async(discord_token))
478
+ active.append("Discord")
479
+
480
+ telegram_token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
481
+ if telegram_token:
482
+ from connectors.telegram_connector import TelegramConnector
483
+ tasks.append(TelegramConnector(brain, telegram_token).run())
484
+ active.append("Telegram")
485
+
486
+ slack_bot_token = os.environ.get("SLACK_BOT_TOKEN", "")
487
+ if slack_bot_token:
488
+ from connectors.slack_connector import SlackConnector
489
+ slack_app_token = os.environ.get("SLACK_APP_TOKEN", "")
490
+ if not slack_app_token:
491
+ logger.error("SLACK_BOT_TOKEN set but SLACK_APP_TOKEN missing")
492
+ sys.exit(1)
493
+ tasks.append(SlackConnector(brain, slack_bot_token, slack_app_token).run())
494
+ active.append("Slack")
495
+
496
+ if not tasks:
497
+ logger.error("No platform tokens configured. Set at least one of: DISCORD_BOT_TOKEN, TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN")
498
+ sys.exit(1)
499
+
500
+ logger.info("Active platforms: %s", ", ".join(active))
501
+ await asyncio.gather(*tasks)
502
+
503
+
504
+ def main() -> None:
505
+ if not os.environ.get("ANTHROPIC_API_KEY"):
506
+ logger.error("ANTHROPIC_API_KEY not set")
507
+ sys.exit(1)
508
+
509
+ doc_files = load_doc_files()
510
+ validate_knowledge(doc_files)
511
+
512
+ topic_files = discover_topics(doc_files)
513
+ topic_list = sorted(topic_files.keys())
514
+ faq_prompt = build_faq_prompt(doc_files)
515
+ classify_prompt = build_classify_system_prompt(topic_list)
516
+
517
+ logger.info("Topics discovered: %s", topic_list)
518
+
519
+ brain = Brain(
520
+ anthropic_client=create_anthropic_client(),
521
+ doc_files=doc_files, topic_files=topic_files,
522
+ faq_prompt=faq_prompt, classify_system_prompt=classify_prompt,
523
+ )
524
+
525
+ logger.info("Starting Multi-Platform Support Agent")
526
+ asyncio.run(run_connectors(brain))
527
+
528
+
529
+ if __name__ == "__main__":
530
+ main()
531
+ `;
532
+ exports.SA_REQUIREMENTS = `discord.py>=2.3.0,<3.0.0
533
+ python-telegram-bot>=21.0,<22.0.0
534
+ slack-bolt>=1.18.0,<2.0.0
535
+ anthropic>=0.40.0,<1.0.0
536
+ `;
537
+ exports.SA_OVERVIEW_MD = `# My Product Overview
538
+
539
+ Welcome to My Product! This is a placeholder overview document.
540
+
541
+ Replace this file with your own product overview and FAQ-tier knowledge.
542
+
543
+ ## Getting Started
544
+
545
+ 1. Sign up at myproduct.com
546
+ 2. Follow the quick start guide
547
+ 3. Start building
548
+ `;
549
+ exports.SA_FAQ_MD = `# FAQ
550
+
551
+ **Q: What is My Product?**
552
+ A: My Product is a platform for doing amazing things. Replace this with your actual FAQ.
553
+
554
+ **Q: How do I get help?**
555
+ A: Ask in the support channel — our support agent will respond!
556
+ `;
557
+ exports.SA_ENV_EXAMPLE = `# Required — auto-injected in production via supported_providers
558
+ ANTHROPIC_API_KEY=
559
+
560
+ # --- Discord (optional) ---
561
+ DISCORD_BOT_TOKEN=
562
+ DISCORD_CHANNEL_IDS=
563
+
564
+ # --- Telegram (optional) ---
565
+ TELEGRAM_BOT_TOKEN=
566
+
567
+ # --- Slack (optional) ---
568
+ SLACK_BOT_TOKEN=
569
+ SLACK_APP_TOKEN=
570
+ `;
package/dist/lib/api.js CHANGED
@@ -51,6 +51,7 @@ exports.downloadCodeBundle = downloadCodeBundle;
51
51
  exports.uploadCodeBundle = uploadCodeBundle;
52
52
  exports.getMyAgent = getMyAgent;
53
53
  exports.getAgentWithFallback = getAgentWithFallback;
54
+ exports.resolveWorkspaceIdForOrg = resolveWorkspaceIdForOrg;
54
55
  exports.downloadCodeBundleAuthenticated = downloadCodeBundleAuthenticated;
55
56
  exports.checkAgentDelete = checkAgentDelete;
56
57
  exports.deleteAgent = deleteAgent;
@@ -290,8 +291,11 @@ async function updateOrg(config, payload) {
290
291
  async function getPublicAgent(config, org, agent, version) {
291
292
  return publicRequest(config, `/public/agents/${org}/${agent}/${version}`);
292
293
  }
293
- async function listMyAgents(config) {
294
- return request(config, 'GET', '/agents');
294
+ async function listMyAgents(config, workspaceId) {
295
+ const headers = {};
296
+ if (workspaceId)
297
+ headers['X-Workspace-Id'] = workspaceId;
298
+ return request(config, 'GET', '/agents', { headers });
295
299
  }
296
300
  async function createAgent(config, data, workspaceId) {
297
301
  const headers = { 'Content-Type': 'application/json' };
@@ -320,7 +324,7 @@ async function downloadCodeBundle(config, org, agent, version) {
320
324
  /**
321
325
  * Upload a code bundle for a hosted tool agent.
322
326
  */
323
- async function uploadCodeBundle(config, agentId, bundlePath, entrypoint) {
327
+ async function uploadCodeBundle(config, agentId, bundlePath, entrypoint, workspaceId) {
324
328
  if (!config.apiKey) {
325
329
  throw new ApiError('Missing API key. Run `orchagent login` first.', 401);
326
330
  }
@@ -338,6 +342,9 @@ async function uploadCodeBundle(config, agentId, bundlePath, entrypoint) {
338
342
  if (entrypoint) {
339
343
  headers['x-entrypoint'] = entrypoint;
340
344
  }
345
+ if (workspaceId) {
346
+ headers['X-Workspace-Id'] = workspaceId;
347
+ }
341
348
  const response = await safeFetch(`${config.apiUrl.replace(/\/$/, '')}/agents/${agentId}/upload`, {
342
349
  method: 'POST',
343
350
  headers,
@@ -351,8 +358,8 @@ async function uploadCodeBundle(config, agentId, bundlePath, entrypoint) {
351
358
  /**
352
359
  * Get single agent by name/version from authenticated endpoint.
353
360
  */
354
- async function getMyAgent(config, agentName, version) {
355
- const agents = await listMyAgents(config);
361
+ async function getMyAgent(config, agentName, version, workspaceId) {
362
+ const agents = await listMyAgents(config, workspaceId);
356
363
  const matching = agents.filter(a => a.name === agentName);
357
364
  if (matching.length === 0)
358
365
  return null;
@@ -364,7 +371,7 @@ async function getMyAgent(config, agentName, version) {
364
371
  /**
365
372
  * Try public endpoint first, fallback to authenticated for private agents.
366
373
  */
367
- async function getAgentWithFallback(config, org, agentName, version) {
374
+ async function getAgentWithFallback(config, org, agentName, version, workspaceId) {
368
375
  try {
369
376
  return await getPublicAgent(config, org, agentName, version);
370
377
  }
@@ -375,16 +382,31 @@ async function getAgentWithFallback(config, org, agentName, version) {
375
382
  if (!config.apiKey) {
376
383
  throw new ApiError(`Agent '${org}/${agentName}@${version}' not found`, 404);
377
384
  }
378
- const userOrg = await getOrg(config);
385
+ const userOrg = await getOrg(config, workspaceId);
379
386
  if (userOrg.slug !== org) {
380
387
  throw new ApiError(`Agent '${org}/${agentName}@${version}' not found`, 404);
381
388
  }
382
- const myAgent = await getMyAgent(config, agentName, version);
389
+ const myAgent = await getMyAgent(config, agentName, version, workspaceId);
383
390
  if (!myAgent) {
384
391
  throw new ApiError(`Agent '${org}/${agentName}@${version}' not found`, 404);
385
392
  }
386
393
  return myAgent;
387
394
  }
395
+ /**
396
+ * Resolve a workspace ID from an org slug.
397
+ * Returns undefined for personal orgs or if unauthenticated.
398
+ */
399
+ async function resolveWorkspaceIdForOrg(config, orgSlug) {
400
+ if (!config.apiKey)
401
+ return undefined;
402
+ try {
403
+ const { workspaces } = await request(config, 'GET', '/workspaces');
404
+ return workspaces.find(w => w.slug === orgSlug)?.id;
405
+ }
406
+ catch {
407
+ return undefined;
408
+ }
409
+ }
388
410
  /**
389
411
  * Download a tool bundle for a private agent using authenticated endpoint.
390
412
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchagent/cli",
3
- "version": "0.3.67",
3
+ "version": "0.3.69",
4
4
  "description": "Command-line interface for orchagent — deploy and run AI agents for your team",
5
5
  "license": "MIT",
6
6
  "author": "orchagent <hello@orchagent.io>",