@hailer/mcp 1.1.10 → 1.1.12

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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-helga-workflow-config
3
3
  description: Manages Hailer workspace configuration as infrastructure-as-code using SDK v0.8.4.
4
- model: sonnet
4
+ model: anthropic/claude-sonnet-4-5
5
5
  tools:
6
6
  bash: true
7
7
  read: true
@@ -53,7 +53,6 @@ For on-demand skills, the orchestrator will say "Load skill X" — use the Skill
53
53
  11. **ACTIVITYLINK FIELDS** - data must be plain string array: `["workflowId"]` NOT `[{workflowId: "..."}]`.
54
54
  12. **NUMBER FIELDS** - Type: `numeric` (not number).
55
55
  13. **FIELD TYPES ARE IMMUTABLE** - Cannot change field type via API. To change text→number: create new field, migrate data, delete old field. Or change in Hailer UI manually.
56
- 14. **CANNOT RENAME WORKFLOWS** - Workflow names cannot be changed via SDK. To "rename": create new workflow with desired name, migrate data, delete old workflow. Or rename in Hailer UI manually.
57
56
 
58
57
  **Delegation to specialists:**
59
58
  - For insight SQL query design → delegate to Viktor (after creating insight entry in insights.ts)
@@ -0,0 +1,68 @@
1
+ # Session Handoff
2
+
3
+ **Last Updated:** 2026-03-04
4
+
5
+ ## What Was Done
6
+
7
+ ### System Prompt — Full End-to-End Wiring
8
+ Wired `systemPrompt` field from Agent Directory through the entire bot pipeline:
9
+
10
+ - **`src/bot-config/constants.ts`** — added `FIELD_KEY_SYSTEM_PROMPT`
11
+ - **`src/bot-config/context.ts`** — `BotSchema.fields.systemPrompt`, `BotCredentials.systemPrompt`, schema discovery, `extractCredentials()`
12
+ - **`src/bot-config/webhooks.ts`** — extract systemPrompt in `extractCredentialsFromActivity()`
13
+ - **`src/bot-config/loader.ts`** — `BotConfigFile` types + `saveBotConfig()` includes systemPrompt
14
+ - **`src/bot/bot-config.ts`** — `BotConfig.systemPrompt`, reads from `orchestrator.systemPrompt`
15
+ - **`src/bot/bot-manager.ts`** — `BotUpdateEntry.systemPrompt`, passes to Bot; smart restart logic: if only systemPrompt changed (credentials same) → `bot.updateSystemPrompt()` without restart
16
+ - **`src/bot/bot.ts`** — `_systemPrompt` instance field (hot-swappable), `updateSystemPrompt()` public method, `buildSystemPrompt()` uses custom prompt + always appends `<bot-identity>` + `<workspace-context>`
17
+ - **`src/commands/seed-config.ts`** — `extractSystemPromptFromActivity()`, wired into orchestrator + specialist output
18
+ - **`src/mcp/webhook-handler.ts`** — `BotEntry.systemPrompt`, `WorkspaceConfig.orchestrator.systemPrompt`, extracts from webhook payload
19
+
20
+ ### System Prompt Design
21
+ - Custom prompt from AI Hub used as full body; `{wsName}`, `{userId}`, `{botName}` substituted at runtime
22
+ - `<bot-identity>` and `<workspace-context>` always appended by bot — never stored
23
+ - Workspace-isolated: each `Bot` instance has own `_systemPrompt`, `BotManager.bots` Map keyed by workspaceId
24
+ - Phase change → full restart picks up new prompt from config file
25
+ - Field edit → webhook fires (with delay) → `handleBotConfigWebhook()` → `updateSystemPrompt()` (no restart)
26
+
27
+ ## Current Work
28
+
29
+ ### Uncommitted — All System Prompt Changes
30
+ 10 files modified, compiles clean (`tsc --noEmit` passes). Not yet committed or pushed.
31
+
32
+ ## Next Steps
33
+
34
+ 1. **Commit the system prompt wiring** — all 10 files are ready
35
+ 2. **Test end-to-end**: edit prompt in AI Hub → wait for webhook → verify bot uses new prompt
36
+ 3. **Cloudflare** — user mentioned needing this (for remote MCP / public webhook URL). See `docs/prd-remote-mcp-server.md` and `docs/prd-remote-mcp-connector.md`
37
+ 4. **`activityId` passthrough** (optional improvement) — pass orchestrator's `activityId` into Bot so it can subscribe to `activities.updated` socket signal for instant prompt updates (vs waiting for webhook)
38
+
39
+ ## Key Decisions
40
+
41
+ - **Hot-swap without restart**: Only credentials change triggers bot restart; systemPrompt-only changes apply live via `updateSystemPrompt()`
42
+ - **Workspace isolation**: Guaranteed by `Map<workspaceId, Bot>` — webhook `cid` field routes to correct bot instance
43
+ - **Webhook-driven updates**: Phase change → webhook (requires public URL). Field-only changes → webhook fires with delay. Socket `activities.updated` would be instant but requires `activityId` passthrough (not yet done)
44
+ - **Prompt structure**: Custom prompt replaces the rules section; `<bot-identity>` + `<workspace-context>` always runtime-injected
45
+
46
+ ## Files Modified (uncommitted)
47
+
48
+ - `src/bot-config/constants.ts` — FIELD_KEY_SYSTEM_PROMPT
49
+ - `src/bot-config/context.ts` — BotSchema, BotCredentials, discovery, extractCredentials
50
+ - `src/bot-config/loader.ts` — BotConfigFile types + saveBotConfig
51
+ - `src/bot-config/webhooks.ts` — extractCredentialsFromActivity
52
+ - `src/bot/bot-config.ts` — BotConfig + loadBotConfigs
53
+ - `src/bot/bot-manager.ts` — BotUpdateEntry, smart restart, updateSystemPrompt call
54
+ - `src/bot/bot.ts` — _systemPrompt field, updateSystemPrompt(), buildSystemPrompt(), email/password getters
55
+ - `src/commands/seed-config.ts` — extractSystemPromptFromActivity + wired into output
56
+ - `src/mcp/webhook-handler.ts` — BotEntry, WorkspaceConfig types + extraction
57
+
58
+ ## Context to Preserve
59
+
60
+ - Agent Directory workflow ID: `69885261c432a499a35313b6`
61
+ - Deployed phase: `8fa543c69749ab4e8f9e3967`, Retired phase: `df66a80fe7cfde1c3b928038`
62
+ - AI Hub app: `695e3665701ef8e3824beebe`, marketplace targetId: `698351e8dda756e689368b60`
63
+ - `systemPrompt` field key must be exactly `systemPrompt` (camelCase) — field type: textarea
64
+ - **seed-config.ts is an independent copy** of field extraction — NOT shared with context.ts. Always update both when adding new Agent Directory fields
65
+ - **Two bot-config readers**: `src/bot-config/loader.ts` (BotContext/webhook path) AND `src/bot/bot-config.ts` (BotManager startup path) — both need updating
66
+ - **webhook-handler.ts has its own local types** (BotEntry, WorkspaceConfig) — update independently
67
+ - Webhook fires on phase changes; field edits may have delay before webhook triggers
68
+ - `app-edit-guard --agent-on` does NOT propagate to subagents — do ai-hub edits directly
@@ -5,11 +5,13 @@
5
5
  * Extracts orchestrator credentials into a flat BotConfig interface.
6
6
  */
7
7
  export interface BotConfig {
8
+ activityId: string;
8
9
  workspaceId: string;
9
10
  workspaceName: string;
10
11
  email: string;
11
12
  password: string;
12
13
  displayName?: string;
14
+ systemPrompt?: string;
13
15
  enabled: boolean;
14
16
  apiHost: string;
15
17
  }
@@ -68,21 +68,43 @@ function loadBotConfigs() {
68
68
  try {
69
69
  const filePath = path.join(configDir, file);
70
70
  const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
71
+ // Load orchestrator
71
72
  const orch = raw.orchestrator;
72
- const hasCredentials = !!orch?.email && !!orch?.password;
73
- configs.push({
74
- workspaceId: raw.workspaceId,
75
- workspaceName: raw.workspaceName,
76
- email: orch?.email ?? '',
77
- password: orch?.password ?? '',
78
- displayName: orch?.displayName,
79
- enabled: hasCredentials,
80
- apiHost: getApiHost(),
81
- });
73
+ if (orch?.email && orch?.password) {
74
+ configs.push({
75
+ activityId: orch.activityId || '',
76
+ workspaceId: raw.workspaceId,
77
+ workspaceName: raw.workspaceName,
78
+ email: orch.email,
79
+ password: orch.password,
80
+ displayName: orch.displayName,
81
+ systemPrompt: orch.systemPrompt,
82
+ enabled: true,
83
+ apiHost: getApiHost(),
84
+ });
85
+ }
86
+ // Load specialists
87
+ if (Array.isArray(raw.specialists)) {
88
+ for (const spec of raw.specialists) {
89
+ if (spec?.email && spec?.password && spec?.enabled !== false) {
90
+ configs.push({
91
+ activityId: spec.activityId || '',
92
+ workspaceId: raw.workspaceId,
93
+ workspaceName: raw.workspaceName,
94
+ email: spec.email,
95
+ password: spec.password,
96
+ displayName: spec.displayName,
97
+ systemPrompt: spec.systemPrompt,
98
+ enabled: true,
99
+ apiHost: getApiHost(),
100
+ });
101
+ }
102
+ }
103
+ }
82
104
  logger.debug('Loaded bot config', {
83
105
  workspaceId: raw.workspaceId,
84
106
  workspaceName: raw.workspaceName,
85
- enabled: hasCredentials,
107
+ botCount: configs.filter(c => c.workspaceId === raw.workspaceId).length,
86
108
  });
87
109
  }
88
110
  catch (error) {
@@ -124,6 +146,7 @@ function saveBotConfig(config) {
124
146
  email: config.email,
125
147
  password: config.password,
126
148
  displayName: config.displayName,
149
+ systemPrompt: config.systemPrompt,
127
150
  } : null,
128
151
  specialists: existing.specialists ?? [],
129
152
  lastSynced: new Date().toISOString(),
@@ -7,23 +7,31 @@
7
7
  import { ToolRegistry } from '../mcp/tool-registry';
8
8
  import { BotConfig } from './bot-config';
9
9
  interface BotUpdateEntry {
10
+ activityId: string;
10
11
  email: string;
11
12
  password: string;
12
13
  botType: string;
13
14
  enabled: boolean;
14
15
  displayName?: string;
16
+ systemPrompt?: string;
15
17
  }
16
18
  export declare class BotManager {
17
19
  private toolRegistry;
18
20
  private anthropicApiKey;
21
+ /** Key: activityId → Bot instance */
19
22
  private bots;
23
+ /** Key: workspaceId → Set of activityIds (for cross-bot awareness) */
24
+ private workspaceBots;
20
25
  private apiHost;
21
26
  constructor(toolRegistry: ToolRegistry, anthropicApiKey: string);
27
+ /** Get all bot userIds in a workspace (for self-message guard) */
28
+ getBotUserIdsForWorkspace(workspaceId: string): Set<string>;
22
29
  startAll(): Promise<void>;
23
30
  startBot(config: BotConfig): Promise<void>;
24
- stopBot(workspaceId: string): Promise<void>;
31
+ stopBot(botKey: string, workspaceId?: string): Promise<void>;
25
32
  stopAll(): Promise<void>;
26
33
  getStatus(): Array<{
34
+ activityId: string;
27
35
  workspaceId: string;
28
36
  connected: boolean;
29
37
  displayName: string;
@@ -31,8 +39,8 @@ export declare class BotManager {
31
39
  getBotCount(): number;
32
40
  /**
33
41
  * Handle hot reload from webhook updates.
34
- * Only processes orchestrator bots. Workspace-isolated only touches
35
- * the bot for the specified workspaceId, never affects other workspaces.
42
+ * Supports all bot types. Routes by activityId so multiple bots
43
+ * can run in the same workspace independently.
36
44
  */
37
45
  handleBotUpdate(workspaceId: string, bot: BotUpdateEntry, action: 'add' | 'update' | 'remove'): Promise<void>;
38
46
  }
@@ -14,13 +14,29 @@ const logger = (0, logger_1.createLogger)({ component: 'bot-manager' });
14
14
  class BotManager {
15
15
  toolRegistry;
16
16
  anthropicApiKey;
17
+ /** Key: activityId → Bot instance */
17
18
  bots = new Map();
19
+ /** Key: workspaceId → Set of activityIds (for cross-bot awareness) */
20
+ workspaceBots = new Map();
18
21
  apiHost;
19
22
  constructor(toolRegistry, anthropicApiKey) {
20
23
  this.toolRegistry = toolRegistry;
21
24
  this.anthropicApiKey = anthropicApiKey;
22
25
  this.apiHost = process.env.BOT_API_BASE_URL || 'https://api.hailer.com';
23
26
  }
27
+ /** Get all bot userIds in a workspace (for self-message guard) */
28
+ getBotUserIdsForWorkspace(workspaceId) {
29
+ const ids = new Set();
30
+ const activityIds = this.workspaceBots.get(workspaceId);
31
+ if (activityIds) {
32
+ for (const actId of activityIds) {
33
+ const bot = this.bots.get(actId);
34
+ if (bot?.botUserId)
35
+ ids.add(bot.botUserId);
36
+ }
37
+ }
38
+ return ids;
39
+ }
24
40
  async startAll() {
25
41
  const configs = (0, bot_config_1.loadBotConfigs)();
26
42
  const enabled = configs.filter(c => c.enabled);
@@ -37,11 +53,13 @@ class BotManager {
37
53
  logger.debug('Bot startup complete', { succeeded, failed });
38
54
  }
39
55
  async startBot(config) {
40
- if (this.bots.has(config.workspaceId)) {
41
- logger.warn('Bot already running', { workspaceId: config.workspaceId });
56
+ const botKey = config.activityId || config.workspaceId; // fallback for legacy configs
57
+ if (this.bots.has(botKey)) {
58
+ logger.warn('Bot already running', { activityId: botKey, workspaceId: config.workspaceId });
42
59
  return;
43
60
  }
44
61
  logger.debug('Starting bot', {
62
+ activityId: botKey,
45
63
  workspaceId: config.workspaceId,
46
64
  workspace: config.workspaceName,
47
65
  });
@@ -52,32 +70,50 @@ class BotManager {
52
70
  anthropicApiKey: this.anthropicApiKey,
53
71
  toolRegistry: this.toolRegistry,
54
72
  workspaceId: config.workspaceId,
73
+ systemPrompt: config.systemPrompt,
74
+ botManager: this,
55
75
  });
56
76
  await bot.start();
57
- this.bots.set(config.workspaceId, bot);
58
- logger.debug('Bot started', { workspaceId: config.workspaceId });
77
+ this.bots.set(botKey, bot);
78
+ // Track in workspace lookup
79
+ if (!this.workspaceBots.has(config.workspaceId)) {
80
+ this.workspaceBots.set(config.workspaceId, new Set());
81
+ }
82
+ this.workspaceBots.get(config.workspaceId).add(botKey);
83
+ logger.debug('Bot started', { activityId: botKey, workspaceId: config.workspaceId });
59
84
  }
60
- async stopBot(workspaceId) {
61
- const bot = this.bots.get(workspaceId);
85
+ async stopBot(botKey, workspaceId) {
86
+ const bot = this.bots.get(botKey);
62
87
  if (!bot)
63
88
  return;
64
89
  await bot.stop();
65
- this.bots.delete(workspaceId);
66
- logger.debug('Bot stopped', { workspaceId });
90
+ this.bots.delete(botKey);
91
+ // Clean up workspace tracking
92
+ if (workspaceId) {
93
+ const wsSet = this.workspaceBots.get(workspaceId);
94
+ if (wsSet) {
95
+ wsSet.delete(botKey);
96
+ if (wsSet.size === 0)
97
+ this.workspaceBots.delete(workspaceId);
98
+ }
99
+ }
100
+ logger.debug('Bot stopped', { activityId: botKey, workspaceId });
67
101
  }
68
102
  async stopAll() {
69
103
  logger.debug('Stopping all bots', { count: this.bots.size });
70
- await Promise.allSettled(Array.from(this.bots.entries()).map(async ([wsId, bot]) => {
104
+ await Promise.allSettled(Array.from(this.bots.entries()).map(async ([botKey, bot]) => {
71
105
  await bot.stop();
72
- logger.debug('Bot stopped', { workspaceId: wsId });
106
+ logger.debug('Bot stopped', { activityId: botKey });
73
107
  }));
74
108
  this.bots.clear();
109
+ this.workspaceBots.clear();
75
110
  }
76
111
  getStatus() {
77
- return Array.from(this.bots.entries()).map(([wsId, bot]) => ({
78
- workspaceId: wsId,
112
+ return Array.from(this.bots.entries()).map(([botKey, bot]) => ({
113
+ activityId: botKey,
114
+ workspaceId: bot.workspaceId || botKey,
79
115
  connected: bot.connected,
80
- displayName: wsId,
116
+ displayName: bot.workspaceId || botKey,
81
117
  }));
82
118
  }
83
119
  getBotCount() {
@@ -85,48 +121,60 @@ class BotManager {
85
121
  }
86
122
  /**
87
123
  * Handle hot reload from webhook updates.
88
- * Only processes orchestrator bots. Workspace-isolated only touches
89
- * the bot for the specified workspaceId, never affects other workspaces.
124
+ * Supports all bot types. Routes by activityId so multiple bots
125
+ * can run in the same workspace independently.
90
126
  */
91
127
  async handleBotUpdate(workspaceId, bot, action) {
92
- if (bot.botType !== 'orchestrator') {
93
- logger.debug('Ignoring non-orchestrator bot update', { workspaceId, botType: bot.botType });
128
+ const botKey = bot.activityId;
129
+ if (!botKey) {
130
+ logger.warn('Bot update missing activityId, skipping', { workspaceId });
94
131
  return;
95
132
  }
96
- const running = this.bots.has(workspaceId);
133
+ const running = this.bots.has(botKey);
97
134
  if (action === 'remove' || !bot.enabled) {
98
135
  if (running) {
99
- logger.info('Stopping bot (disabled via webhook)', { workspaceId });
100
- await this.stopBot(workspaceId);
136
+ logger.info('Stopping bot (disabled via webhook)', { activityId: botKey, workspaceId });
137
+ await this.stopBot(botKey, workspaceId);
101
138
  }
102
139
  return;
103
140
  }
104
141
  // Validate credentials before proceeding
105
142
  if (!/.+@.+\..+/.test(bot.email) || !bot.password || bot.password.length < 6) {
106
- logger.warn('Invalid bot credentials, skipping', { workspaceId });
143
+ logger.warn('Invalid bot credentials, skipping', { activityId: botKey, workspaceId });
107
144
  return;
108
145
  }
109
146
  // action is 'add' or 'update' with enabled=true
110
147
  if (running) {
111
- // Restart credentials or config may have changed
112
- logger.info('Restarting bot (updated via webhook)', { workspaceId });
113
- await this.stopBot(workspaceId);
148
+ const runningBot = this.bots.get(botKey);
149
+ // If only the system prompt changed, update it live — no restart needed
150
+ const credentialsChanged = runningBot.email !== bot.email || runningBot.password !== bot.password;
151
+ if (!credentialsChanged) {
152
+ logger.info('Updating system prompt live (no restart needed)', { activityId: botKey, workspaceId });
153
+ runningBot.updateSystemPrompt(bot.systemPrompt);
154
+ return;
155
+ }
156
+ // Credentials changed — full restart required
157
+ logger.info('Restarting bot (credentials updated via webhook)', { activityId: botKey, workspaceId });
158
+ await this.stopBot(botKey, workspaceId);
114
159
  }
115
160
  const config = {
161
+ activityId: botKey,
116
162
  workspaceId,
117
163
  workspaceName: workspaceId,
118
164
  email: bot.email,
119
165
  password: bot.password,
120
166
  displayName: bot.displayName,
167
+ systemPrompt: bot.systemPrompt,
121
168
  enabled: true,
122
169
  apiHost: this.apiHost,
123
170
  };
124
171
  try {
125
172
  await this.startBot(config);
126
- logger.info('Bot started via webhook', { workspaceId, displayName: bot.displayName });
173
+ logger.info('Bot started via webhook', { activityId: botKey, workspaceId, displayName: bot.displayName });
127
174
  }
128
175
  catch (error) {
129
176
  logger.error('Failed to start bot via webhook', {
177
+ activityId: botKey,
130
178
  workspaceId,
131
179
  error: error instanceof Error ? error.message : String(error),
132
180
  });
package/dist/bot/bot.d.ts CHANGED
@@ -18,6 +18,7 @@ export declare class Bot {
18
18
  private workspaceOverview;
19
19
  private userId;
20
20
  private _workspaceId;
21
+ private _systemPrompt;
21
22
  private conversationManager;
22
23
  private messageClassifier;
23
24
  private messageFormatter;
@@ -45,6 +46,7 @@ export declare class Bot {
45
46
  private adminUserIds;
46
47
  private ownerUserIds;
47
48
  private config;
49
+ private botManager;
48
50
  constructor(config: {
49
51
  email: string;
50
52
  password: string;
@@ -53,9 +55,20 @@ export declare class Bot {
53
55
  model?: string;
54
56
  toolRegistry: ToolRegistry;
55
57
  workspaceId?: string;
58
+ systemPrompt?: string;
59
+ botManager?: any;
56
60
  });
61
+ get email(): string;
62
+ get password(): string;
63
+ /**
64
+ * Hot-update the system prompt without restarting the bot.
65
+ * Takes effect on the next message processed.
66
+ */
67
+ updateSystemPrompt(prompt: string | undefined): void;
57
68
  get connected(): boolean;
58
69
  get workspaceId(): string | undefined;
70
+ /** Exposed for BotManager cross-bot awareness */
71
+ get botUserId(): string;
59
72
  start(): Promise<void>;
60
73
  stop(): Promise<void>;
61
74
  private handleSignal;
package/dist/bot/bot.js CHANGED
@@ -72,6 +72,19 @@ const UNGATED_TOOLS = new Set([
72
72
  'fetch_discussion_messages',
73
73
  'get_activity_from_discussion',
74
74
  ]);
75
+ /** Minimal tool set for SIMPLE-routed messages (greetings, quick lookups). */
76
+ const SIMPLE_TOOLS = new Set([
77
+ 'list_workflows_minimal',
78
+ 'list_activities',
79
+ 'show_activity_by_id',
80
+ 'count_activities',
81
+ 'search_workspace_users',
82
+ 'get_workspace_balance',
83
+ 'list_my_discussions',
84
+ 'fetch_discussion_messages',
85
+ 'add_discussion_message',
86
+ 'get_insight_data',
87
+ ]);
75
88
  const MODEL_HAIKU = 'claude-haiku-4-5-20251001';
76
89
  const MODEL_SONNET = 'claude-sonnet-4-5-20250929';
77
90
  const MAX_TOOL_ITERATIONS = 10;
@@ -91,6 +104,8 @@ class Bot {
91
104
  workspaceOverview = '';
92
105
  userId = '';
93
106
  _workspaceId;
107
+ // Live-updatable system prompt (separate from config so it can be hot-swapped)
108
+ _systemPrompt;
94
109
  // Services
95
110
  conversationManager = null;
96
111
  messageClassifier = null;
@@ -121,23 +136,44 @@ class Bot {
121
136
  ownerUserIds = new Set();
122
137
  // Config
123
138
  config;
139
+ botManager; // BotManager ref for cross-bot awareness
124
140
  constructor(config) {
125
141
  this.config = {
126
142
  ...config,
127
143
  model: config.model || 'claude-haiku-4-5-20251001',
128
144
  };
145
+ this.botManager = config.botManager || null;
146
+ this._systemPrompt = config.systemPrompt;
129
147
  this.logger = (0, logger_1.createLogger)({ component: 'Bot' });
130
148
  this.clientManager = new hailer_clients_1.HailerClientManager(config.apiHost, config.email, config.password);
131
149
  this.toolExecutor = new tool_executor_1.ToolExecutor(config.toolRegistry);
132
150
  }
151
+ get email() { return this.config.email; }
152
+ get password() { return this.config.password; }
153
+ /**
154
+ * Hot-update the system prompt without restarting the bot.
155
+ * Takes effect on the next message processed.
156
+ */
157
+ updateSystemPrompt(prompt) {
158
+ this._systemPrompt = prompt;
159
+ this.logger.info('System prompt updated live', { hasPrompt: !!prompt });
160
+ }
133
161
  get connected() {
134
162
  return this._connected;
135
163
  }
136
164
  get workspaceId() {
137
165
  return this._workspaceId;
138
166
  }
167
+ /** Exposed for BotManager cross-bot awareness */
168
+ get botUserId() {
169
+ return this.userId;
170
+ }
139
171
  // ===== LIFECYCLE =====
140
172
  async start() {
173
+ if (this._connected) {
174
+ this.logger.warn('Bot.start() called while already connected, ignoring');
175
+ return;
176
+ }
141
177
  this.logger.debug('Starting bot', { email: this.config.email });
142
178
  // 1. Connect to Hailer
143
179
  this.client = await this.clientManager.connect();
@@ -271,10 +307,16 @@ class Bot {
271
307
  return;
272
308
  try {
273
309
  const rawMsgId = signal.data.msg_id;
274
- if (rawMsgId) {
275
- if (this.processedMessageIds.has(rawMsgId))
310
+ const discussionId = signal.data.discussion;
311
+ const dedupKey = rawMsgId || (discussionId ? `${discussionId}:${signal.data.uid}:${signal.data.created}` : null);
312
+ if (dedupKey) {
313
+ if (this.processedMessageIds.has(dedupKey))
276
314
  return;
277
- this.processedMessageIds.add(rawMsgId);
315
+ this.processedMessageIds.add(dedupKey);
316
+ }
317
+ else {
318
+ this.logger.warn('messenger.new signal has no dedup key, skipping');
319
+ return;
278
320
  }
279
321
  const message = await this.messageClassifier.extractIncomingMessage(signal);
280
322
  if (!message) {
@@ -282,9 +324,14 @@ class Bot {
282
324
  this.processedMessageIds.delete(rawMsgId);
283
325
  return;
284
326
  }
285
- // Self-message guard: prevent bot feedback loops
327
+ // Self-message guard: prevent bot feedback loops (checks ALL bots in this workspace)
286
328
  if (message.senderId === this.userId)
287
329
  return;
330
+ if (this.botManager && this._workspaceId) {
331
+ const botUserIds = this.botManager.getBotUserIdsForWorkspace(this._workspaceId);
332
+ if (botUserIds.has(message.senderId))
333
+ return;
334
+ }
288
335
  // Rate limiting
289
336
  const now = Date.now();
290
337
  const cutoff = now - RATE_LIMIT_WINDOW;
@@ -598,7 +645,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
598
645
  async runLlmLoop(message, route, signal) {
599
646
  const conversation = this.conversationManager.getConversation(message.discussionId);
600
647
  const systemPrompt = this.buildSystemPrompt();
601
- const tools = this.getAnthropicTools();
648
+ const tools = this.getAnthropicTools(route.classification);
602
649
  const processingStartTime = Date.now();
603
650
  for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
604
651
  const cachedConversation = this.conversationManager.prepareForCaching(conversation);
@@ -987,41 +1034,69 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
987
1034
  buildSystemPrompt() {
988
1035
  const wsName = this.init?.network?.name || 'Workspace';
989
1036
  const botName = this.getBotDisplayName();
990
- return `You are a workspace assistant for "${wsName}" on Hailer.
991
-
992
- <bot-identity>
1037
+ const identity = `<bot-identity>
993
1038
  Your user ID: ${this.userId}
994
1039
  Your display name: ${botName}
995
- </bot-identity>
1040
+ Workspace: ${wsName}
1041
+ </bot-identity>`;
1042
+ // Platform rules — always applied regardless of custom prompt.
1043
+ // These are operational requirements that keep the bot functional.
1044
+ const platformRules = `<platform-rules>
1045
+ - Use tools to answer questions — don't guess or make up data.
1046
+ - When a tool call fails, tell the user what failed and why. Never silently try alternatives.
1047
+ - For bulk operations, batch into groups of 25-50 per tool call.
1048
+ - NEVER output raw URLs like https://app.hailer.com/...
996
1049
 
997
- <rules>
1050
+ HAILERTAG LINKING (mandatory):
1051
+ - When referencing activities, discussions, or users, ALWAYS use: [hailerTag|Display Name](objectId)
1052
+ - Examples:
1053
+ [hailerTag|AC Milan deal](691ffe654217e9e8434e578a)
1054
+ [hailerTag|John Smith](691ffe654217e9e8434e5123)
1055
+ - Tool responses include both names and IDs — use them directly in this format.
1056
+ - NEVER use hailerTag for workflows, phases, teams, or groups — these don't support linking. Just use their plain text names.
1057
+ - NEVER output bare 24-character hex IDs to the user.
1058
+ </platform-rules>`;
1059
+ const wsContext = `<workspace-context>
1060
+ ${this.workspaceOverview}
1061
+ </workspace-context>`;
1062
+ const customPrompt = this._systemPrompt?.trim();
1063
+ if (customPrompt) {
1064
+ // Custom prompt = the bot's mission brief. It defines personality, scope, and behavior.
1065
+ // Platform rules and identity are always appended — they keep the bot functional.
1066
+ const body = customPrompt
1067
+ .replace(/\{wsName\}/g, wsName)
1068
+ .replace(/\{userId\}/g, this.userId)
1069
+ .replace(/\{botName\}/g, botName);
1070
+ return `${body}\n\n${platformRules}\n\n${identity}\n\n${wsContext}`;
1071
+ }
1072
+ // Default prompt — general-purpose workspace assistant with behavioral defaults.
1073
+ const defaultBehavior = `<behavior>
998
1074
  - Be concise. Short answers, no filler.
999
1075
  - Never list your capabilities unprompted. If asked what you can do, give a 1-2 sentence summary based on the workspace context below, not a feature dump.
1000
1076
  - Never use emojis unless the user does first.
1001
- - Use tools to answer questions - don't guess or make up data.
1002
- - When a task will require multiple steps (investigating errors, fixing data, bulk lookups), briefly tell the user what you're specifically about to do before starting. Be concrete: "I'll check each insight's SQL and fix any broken column references." NEVER use vague filler like "Let me look into that" — always name the actual action.
1077
+ - When a task will require multiple steps, briefly tell the user what you're about to do. Be concrete — never use vague filler like "Let me look into that".
1003
1078
  - When showing data, use clean formatting (tables, bullet points). Don't over-explain.
1004
1079
  - If a request is ambiguous, ask a clarifying question instead of guessing.
1005
- - Always reference actual workspace data (workflow names, field values) - never speak in generic terms.
1006
- - For bulk operations (creating/updating many items), batch into groups of 25-50 per tool call. Never try to create more than 50 items in a single tool call.
1007
- - When a tool call fails, ALWAYS tell the user what failed and why. Never silently try alternatives. If an insight has a SQL error, tell the user the insight is broken, explain what's wrong (e.g. missing column), and offer to fix it using update_insight. Fix the root cause, don't work around it.
1008
- - NEVER output raw URLs like https://app.hailer.com/...
1009
- - When referencing activities, discussions, or users, use hailerTag format: [hailerTag|Display Name](objectId)
1010
- Examples: [hailerTag|AC Milan](691ffe654217e9e8434e578a) or [hailerTag|John Smith](691ffe654217e9e8434e5123)
1011
- You always have the name and ID from tool responses - use them directly in this format.
1012
- </rules>
1080
+ - Always reference actual workspace data (workflow names, field values) never speak in generic terms.
1081
+ </behavior>`;
1082
+ return `You are a workspace assistant for "${wsName}" on Hailer.
1013
1083
 
1014
- <workspace-context>
1015
- ${this.workspaceOverview}
1016
- </workspace-context>`;
1084
+ ${identity}
1085
+
1086
+ ${platformRules}
1087
+
1088
+ ${defaultBehavior}
1089
+
1090
+ ${wsContext}`;
1017
1091
  }
1018
1092
  // ===== TOOLS =====
1019
- getAnthropicTools() {
1093
+ getAnthropicTools(classification) {
1094
+ const allowedTools = classification === 'SIMPLE' ? SIMPLE_TOOLS : BOT_TOOLS;
1020
1095
  const defs = this.toolExecutor.getToolDefinitions({
1021
1096
  allowedGroups: [tool_registry_1.ToolGroup.READ, tool_registry_1.ToolGroup.WRITE, tool_registry_1.ToolGroup.PLAYGROUND, tool_registry_1.ToolGroup.BOT_INTERNAL],
1022
1097
  });
1023
1098
  return defs
1024
- .filter(d => BOT_TOOLS.has(d.name))
1099
+ .filter(d => allowedTools.has(d.name))
1025
1100
  .map(d => ({
1026
1101
  name: d.name,
1027
1102
  description: d.description,
@@ -204,7 +204,10 @@ class HailerClientManager {
204
204
  if (!this.signalHandlers.has(eventType)) {
205
205
  this.signalHandlers.set(eventType, []);
206
206
  }
207
- this.signalHandlers.get(eventType).push(handler);
207
+ const handlers = this.signalHandlers.get(eventType);
208
+ if (!handlers.includes(handler)) {
209
+ handlers.push(handler);
210
+ }
208
211
  }
209
212
  // Remove a signal handler
210
213
  offSignal(eventType, handler) {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SignalHandler = void 0;
4
4
  const logger_1 = require("../lib/logger");
5
+ const webhooks_1 = require("../bot-config/webhooks");
5
6
  // Optional: bot-config only available in bot server mode, not MCP terminal
6
7
  let reloadConfigFromHailer = null;
7
8
  let setActiveWorkspace = null;
@@ -169,18 +170,15 @@ class SignalHandler {
169
170
  prevPhase: data.prevPhase || data.meta?.prevPhase
170
171
  });
171
172
  // Handle Agent Directory phase changes for bot enable/disable
172
- // DISABLED: Now using webhook instead of socket signals for Agent Directory updates
173
- // The webhook handler (src/mcp/webhook-handler.ts) handles bot config updates
174
- // Keeping this code for potential fallback if webhook is unavailable
175
- // if (processId && phase && activityIdRaw) {
176
- // const activityIds = Array.isArray(activityIdRaw)
177
- // ? activityIdRaw
178
- // : [activityIdRaw];
179
- //
180
- // handleActivityPhaseChange(processId, activityIds, phase).catch(err => {
181
- // logger.error('Failed to handle activity phase change', err);
182
- // });
183
- // }
173
+ // Socket signals as primary path, webhook as backup
174
+ if (processId && phase && activityIdRaw) {
175
+ const activityIds = Array.isArray(activityIdRaw)
176
+ ? activityIdRaw
177
+ : [activityIdRaw];
178
+ (0, webhooks_1.handleActivityPhaseChange)(processId, activityIds, phase).catch(err => {
179
+ logger.error('Failed to handle activity phase change', err);
180
+ });
181
+ }
184
182
  }
185
183
  handleActivityCreated(signal) {
186
184
  const data = signal.data;
@@ -49,6 +49,7 @@ interface BotEntry {
49
49
  botType: string;
50
50
  enabled: boolean;
51
51
  displayName?: string;
52
+ systemPrompt?: string;
52
53
  }
53
54
  interface WorkspaceConfig {
54
55
  workspaceId: string;
@@ -59,6 +60,7 @@ interface WorkspaceConfig {
59
60
  email: string;
60
61
  password: string;
61
62
  displayName?: string;
63
+ systemPrompt?: string;
62
64
  };
63
65
  specialists: BotEntry[];
64
66
  lastSynced: string;
@@ -221,6 +221,7 @@ function handleBotConfigWebhook(payload) {
221
221
  const botType = getFieldValue(payload.fields, 'botType');
222
222
  const userId = getFieldValue(payload.fields, 'hailerProfile');
223
223
  const schemaConfigStr = getFieldValue(payload.fields, 'schemaConfig');
224
+ const systemPrompt = getFieldValue(payload.fields, 'systemPrompt') || undefined;
224
225
  // Validate required fields
225
226
  if (!email || !password) {
226
227
  logger.warn('Webhook missing credentials', {
@@ -262,6 +263,7 @@ function handleBotConfigWebhook(payload) {
262
263
  botType: botType || 'unknown',
263
264
  enabled,
264
265
  displayName: payload.name, // Activity name from Agent Directory
266
+ systemPrompt,
265
267
  };
266
268
  let action;
267
269
  // Handle orchestrator
@@ -273,6 +275,7 @@ function handleBotConfigWebhook(payload) {
273
275
  email,
274
276
  password,
275
277
  displayName: payload.name,
278
+ systemPrompt,
276
279
  };
277
280
  action = 'update';
278
281
  logger.info('Updated orchestrator', { workspaceId, email: (0, config_1.maskEmail)(email), displayName: payload.name });
@@ -0,0 +1,24 @@
1
+ # Bot Config Field Wiring Patterns
2
+
3
+ **Date:** 2026-03-04
4
+ **Context:** Adding systemPrompt field to Agent Directory pipeline
5
+
6
+ ## Key Gotchas
7
+
8
+ ### seed-config.ts is an independent copy
9
+ `src/commands/seed-config.ts` has its own field extraction logic completely separate from `src/bot-config/context.ts`. When adding a new Agent Directory field, you MUST update BOTH files — they don't share code.
10
+
11
+ ### Two separate bot-config readers
12
+ - `src/bot-config/loader.ts` — BotContext system, used by webhook/signal path
13
+ - `src/bot/bot-config.ts` — simple flat reader, used by BotManager on startup
14
+
15
+ Both read from `.bot-config/*.json` but in different ways. Both need updating when adding new fields.
16
+
17
+ ### webhook-handler.ts has its own local types
18
+ `BotEntry` and `WorkspaceConfig` are defined locally inside `webhook-handler.ts`, not imported from anywhere. Must update them independently when adding fields.
19
+
20
+ ### Webhook fires on phase changes only (with delay)
21
+ Field edits (e.g. systemPrompt) trigger the webhook but with a delay — Hailer batches/debounces. Phase changes are more immediate. For truly instant field-change detection, subscribe to the `activities.updated` socket signal inside the Bot (requires knowing the bot's own `activityId`).
22
+
23
+ ### Hot-swap system prompts without restart
24
+ Pattern for live-updatable config in Bot: store as a separate instance field (`_systemPrompt`) distinct from `this.config`. Expose `updateSystemPrompt(prompt)` public method. BotManager can call it without restarting the bot — only restart when credentials actually change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hailer/mcp",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "config": {
5
5
  "docker": {
6
6
  "registry": "registry.gitlab.com/hailer-repos/hailer-mcp"
@@ -25,7 +25,8 @@
25
25
  "release:patch": "npm version patch -m 'chore: release v%s' && git push && git push --tags && npm run build && npm publish --access public",
26
26
  "release:minor": "npm version minor -m 'chore: release v%s' && git push && git push --tags && npm run build && npm publish --access public",
27
27
  "release:major": "npm version major -m 'chore: release v%s' && git push && git push --tags && npm run build && npm publish --access public",
28
- "seed-config": "tsx src/commands/seed-config.ts"
28
+ "seed-config": "tsx src/commands/seed-config.ts",
29
+ "postinstall": "node scripts/postinstall.cjs"
29
30
  },
30
31
  "dependencies": {
31
32
  "@anthropic-ai/sdk": "^0.54.0",
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postinstall: Copies .claude/ (agents, skills, hooks) to the project root.
4
+ * Runs automatically after `npm install @hailer/mcp`.
5
+ * Skips when installing in the hailer-mcp repo itself (development).
6
+ */
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ // Find the project root by walking up from node_modules/@hailer/mcp/
11
+ function findProjectRoot() {
12
+ let dir = __dirname;
13
+ // Walk up until we find a package.json that isn't ours
14
+ for (let i = 0; i < 10; i++) {
15
+ dir = path.dirname(dir);
16
+ const pkgPath = path.join(dir, 'package.json');
17
+ if (fs.existsSync(pkgPath)) {
18
+ try {
19
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
20
+ // Skip if this is our own package.json
21
+ if (pkg.name === '@hailer/mcp') continue;
22
+ return dir;
23
+ } catch { continue; }
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function copyDir(src, dest) {
30
+ if (!fs.existsSync(src)) return;
31
+ fs.mkdirSync(dest, { recursive: true });
32
+
33
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
34
+ const srcPath = path.join(src, entry.name);
35
+ const destPath = path.join(dest, entry.name);
36
+
37
+ if (entry.isDirectory()) {
38
+ copyDir(srcPath, destPath);
39
+ } else {
40
+ // Don't overwrite user's local settings
41
+ if (entry.name === 'settings.local.json' && fs.existsSync(destPath)) continue;
42
+ fs.copyFileSync(srcPath, destPath);
43
+ }
44
+ }
45
+ }
46
+
47
+ const projectRoot = findProjectRoot();
48
+
49
+ // Skip if we can't find a project root or if running in dev (our own repo)
50
+ if (!projectRoot) {
51
+ console.log('@hailer/mcp: skipping agent install (no project root found)');
52
+ process.exit(0);
53
+ }
54
+
55
+ const src = path.join(__dirname, '..', '.claude');
56
+ const dest = path.join(projectRoot, '.claude');
57
+
58
+ if (!fs.existsSync(src)) {
59
+ console.log('@hailer/mcp: no .claude/ directory in package, skipping');
60
+ process.exit(0);
61
+ }
62
+
63
+ copyDir(src, dest);
64
+ console.log(`@hailer/mcp: agents installed to ${dest}`);