niahere 0.3.12 → 0.4.0

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/src/mcp/server.ts CHANGED
@@ -1,377 +1,20 @@
1
1
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
2
- import { z } from "zod";
3
- import * as handlers from "./tools";
2
+ import { NIA_TOOLS } from "./tools/table";
4
3
  import type { McpSourceContext } from "./index";
5
4
 
5
+ /**
6
+ * In-process MCP server for the Claude Agent SDK. Maps over the single
7
+ * `NIA_TOOLS` table so the in-process and loopback-HTTP transports stay in
8
+ * lockstep — there is no second tool list to drift.
9
+ */
6
10
  export function createNiaMcpServer(sourceCtx?: McpSourceContext) {
7
11
  return createSdkMcpServer({
8
12
  name: "nia",
9
13
  version: "0.1.0",
10
- tools: [
11
- tool("list_jobs", "List all scheduled jobs with status and next run time", {}, async () => ({
12
- content: [{ type: "text" as const, text: await handlers.listJobs() }],
14
+ tools: NIA_TOOLS.map((t) =>
15
+ tool(t.name, t.description, t.schema, async (args: unknown) => ({
16
+ content: [{ type: "text" as const, text: await t.handler(args, sourceCtx) }],
13
17
  })),
14
- tool(
15
- "add_job",
16
- "Create a new scheduled job. Supports cron expressions (0 9 * * *), interval durations (5m, 2h, 1d), or one-time ISO timestamps.",
17
- {
18
- name: z.string().describe("Unique job name"),
19
- schedule: z.string().describe("Cron expression, duration string, or ISO timestamp"),
20
- prompt: z
21
- .string()
22
- .describe(
23
- "What the job should do. A non-empty ~/.niahere/jobs/<job-name>/prompt.md overrides this database prompt at runtime.",
24
- ),
25
- schedule_type: z.enum(["cron", "interval", "once"]).default("cron").describe("Schedule type"),
26
- always: z.boolean().default(false).describe("If true, runs 24/7 ignoring active hours"),
27
- agent: z
28
- .string()
29
- .optional()
30
- .describe("Agent name to use for this job (loads agent's AGENT.md as system prompt)"),
31
- employee: z
32
- .string()
33
- .optional()
34
- .describe("Employee name to use for this job (loads employee identity, runs in employee's repo)"),
35
- stateless: z
36
- .boolean()
37
- .default(false)
38
- .describe("If true, disables working memory (no state.md injection or workspace)"),
39
- model: z
40
- .string()
41
- .optional()
42
- .describe("Model override for this job (e.g. haiku, sonnet, opus). Overrides agent and global model."),
43
- },
44
- async (args) => ({
45
- content: [{ type: "text" as const, text: await handlers.addJob(args) }],
46
- }),
47
- ),
48
- tool(
49
- "update_job",
50
- "Update an existing job's schedule, prompt, always flag, agent, employee, model, stateless, or schedule_type. Only pass fields you want to change.",
51
- {
52
- name: z.string().describe("Job name to update"),
53
- schedule: z
54
- .string()
55
- .optional()
56
- .describe("New schedule (cron expression, interval duration, or ISO timestamp)"),
57
- prompt: z
58
- .string()
59
- .optional()
60
- .describe(
61
- "New database prompt. A non-empty ~/.niahere/jobs/<job-name>/prompt.md overrides this at runtime.",
62
- ),
63
- always: z.boolean().optional().describe("If true, runs 24/7 ignoring active hours"),
64
- agent: z.string().nullable().optional().describe("Agent name (set null to remove agent)"),
65
- employee: z.string().nullable().optional().describe("Employee name (set null to remove employee)"),
66
- model: z.string().nullable().optional().describe("Model override (set null to remove and use default)"),
67
- stateless: z
68
- .boolean()
69
- .optional()
70
- .describe("If true, disables working memory (no state.md injection or workspace)"),
71
- schedule_type: z
72
- .enum(["cron", "interval", "once"])
73
- .optional()
74
- .describe("Schedule type (must match the schedule format)"),
75
- },
76
- async (args) => ({
77
- content: [{ type: "text" as const, text: await handlers.updateJob(args) }],
78
- }),
79
- ),
80
- tool(
81
- "remove_job",
82
- "Delete a scheduled job",
83
- { name: z.string().describe("Job name to remove") },
84
- async (args) => ({
85
- content: [
86
- {
87
- type: "text" as const,
88
- text: await handlers.removeJob(args.name),
89
- },
90
- ],
91
- }),
92
- ),
93
- tool(
94
- "enable_job",
95
- "Enable a disabled job",
96
- { name: z.string().describe("Job name to enable") },
97
- async (args) => ({
98
- content: [
99
- {
100
- type: "text" as const,
101
- text: await handlers.enableJob(args.name),
102
- },
103
- ],
104
- }),
105
- ),
106
- tool(
107
- "disable_job",
108
- "Disable a job (stops it from running)",
109
- { name: z.string().describe("Job name to disable") },
110
- async (args) => ({
111
- content: [
112
- {
113
- type: "text" as const,
114
- text: await handlers.disableJob(args.name),
115
- },
116
- ],
117
- }),
118
- ),
119
- tool(
120
- "archive_job",
121
- "Archive a job (out of sight, won't run). Use unarchive_job to bring it back.",
122
- { name: z.string().describe("Job name to archive") },
123
- async (args) => ({
124
- content: [{ type: "text" as const, text: await handlers.archiveJob(args.name) }],
125
- }),
126
- ),
127
- tool(
128
- "unarchive_job",
129
- "Unarchive a job back to disabled state. Use enable_job after to start running it.",
130
- { name: z.string().describe("Job name to unarchive") },
131
- async (args) => ({
132
- content: [{ type: "text" as const, text: await handlers.unarchiveJob(args.name) }],
133
- }),
134
- ),
135
- tool(
136
- "run_job",
137
- "Trigger a job to run immediately on the next scheduler tick",
138
- { name: z.string().describe("Job name to run now") },
139
- async (args) => ({
140
- content: [
141
- {
142
- type: "text" as const,
143
- text: await handlers.runJobNow(args.name),
144
- },
145
- ],
146
- }),
147
- ),
148
- tool(
149
- "send_message",
150
- "Send a message via configured channel. By default sends to the current context (if in a Slack thread, replies there; otherwise DMs the owner). Use target='dm' to force a DM regardless of context, or target='thread' to explicitly reply in the current thread.",
151
- {
152
- text: z.string().describe("Message text to send"),
153
- channel: z.string().optional().describe("Channel name (telegram, slack). Omit to use default."),
154
- media_path: z
155
- .string()
156
- .optional()
157
- .describe("Absolute path to a file to send as an attachment (image, document)"),
158
- target: z
159
- .enum(["auto", "dm", "thread"])
160
- .default("auto")
161
- .describe(
162
- "Where to send: 'auto' (current context — thread if in one, else DM), 'dm' (always DM the owner), 'thread' (reply in current thread)",
163
- ),
164
- },
165
- async (args) => ({
166
- content: [
167
- {
168
- type: "text" as const,
169
- text: await handlers.sendMessage(args.text, args.channel, args.media_path, sourceCtx, args.target),
170
- },
171
- ],
172
- }),
173
- ),
174
- tool(
175
- "list_messages",
176
- "Read recent chat history",
177
- {
178
- limit: z.number().default(20).describe("Number of messages to return"),
179
- room: z.string().optional().describe("Filter by room name"),
180
- },
181
- async (args) => ({
182
- content: [
183
- {
184
- type: "text" as const,
185
- text: await handlers.listMessages(args.limit, args.room),
186
- },
187
- ],
188
- }),
189
- ),
190
- tool(
191
- "list_sessions",
192
- "Browse past conversation sessions with previews. Returns session IDs you can pass to read_session.",
193
- {
194
- room: z.string().optional().describe("Filter by room name"),
195
- limit: z.number().default(10).describe("Number of sessions to return"),
196
- },
197
- async (args) => ({
198
- content: [
199
- {
200
- type: "text" as const,
201
- text: await handlers.listSessions(args.limit, args.room),
202
- },
203
- ],
204
- }),
205
- ),
206
- tool(
207
- "search_messages",
208
- "Search across all past messages by keyword. Returns matching messages with session IDs for deeper reading.",
209
- {
210
- query: z.string().describe("Text to search for in message content"),
211
- room: z.string().optional().describe("Filter by room name"),
212
- limit: z.number().default(20).describe("Max results to return"),
213
- },
214
- async (args) => ({
215
- content: [
216
- {
217
- type: "text" as const,
218
- text: await handlers.searchMessages(args.query, args.limit, args.room),
219
- },
220
- ],
221
- }),
222
- ),
223
- tool(
224
- "read_session",
225
- "Load the full transcript of a specific conversation session. Use list_sessions or search_messages to find session IDs.",
226
- {
227
- session_id: z.string().describe("Session ID to read"),
228
- },
229
- async (args) => ({
230
- content: [
231
- {
232
- type: "text" as const,
233
- text: await handlers.readSession(args.session_id),
234
- },
235
- ],
236
- }),
237
- ),
238
- tool(
239
- "add_watch_channel",
240
- "Add or update a Slack watch channel. Watch channels receive ALL messages (not just @mentions). Behavior is optional — if omitted, loads watches/<channel_name>/behavior.md at runtime. If a single word, it names a different watch dir. If prose (with spaces), treated as inline behavior. Takes effect on next message (hot-reloads).",
241
- {
242
- name: z
243
- .string()
244
- .describe(
245
- "Slack channel key as 'channel_id#channel_name', e.g. 'C1234567890#ask-kay-thread-notifications'",
246
- ),
247
- behavior: z
248
- .string()
249
- .optional()
250
- .describe(
251
- "Optional. Omit to load watches/<channel_name>/behavior.md. A single word names a different watch dir. Prose (with spaces) is inline behavior.",
252
- ),
253
- },
254
- async (args) => ({
255
- content: [
256
- {
257
- type: "text" as const,
258
- text: handlers.addWatchChannel(args.name, args.behavior),
259
- },
260
- ],
261
- }),
262
- ),
263
- tool(
264
- "remove_watch_channel",
265
- "Remove a Slack watch channel. Takes effect on next message (hot-reloads).",
266
- {
267
- name: z
268
- .string()
269
- .describe("Slack channel key to stop watching (e.g. 'C1234567890#ask-kay-thread-notifications')"),
270
- },
271
- async (args) => ({
272
- content: [
273
- {
274
- type: "text" as const,
275
- text: handlers.removeWatchChannel(args.name),
276
- },
277
- ],
278
- }),
279
- ),
280
- tool(
281
- "enable_watch_channel",
282
- "Enable a disabled Slack watch channel. Takes effect on next message (hot-reloads).",
283
- {
284
- name: z.string().describe("Slack channel key to enable"),
285
- },
286
- async (args) => ({
287
- content: [
288
- {
289
- type: "text" as const,
290
- text: handlers.enableWatchChannel(args.name),
291
- },
292
- ],
293
- }),
294
- ),
295
- tool(
296
- "disable_watch_channel",
297
- "Disable a Slack watch channel without removing it. Takes effect on next message (hot-reloads).",
298
- {
299
- name: z.string().describe("Slack channel key to disable"),
300
- },
301
- async (args) => ({
302
- content: [
303
- {
304
- type: "text" as const,
305
- text: handlers.disableWatchChannel(args.name),
306
- },
307
- ],
308
- }),
309
- ),
310
- tool(
311
- "add_rule",
312
- "Add a behavioral rule. Rules are loaded into every session and take effect without restart. Use for 'from now on' / 'always' / 'never' type instructions.",
313
- {
314
- rule: z.string().describe("The rule to add (e.g. 'stamp updates: 1-2 lines max, no preamble')"),
315
- },
316
- async (args) => ({
317
- content: [{ type: "text" as const, text: handlers.addRule(args.rule) }],
318
- }),
319
- ),
320
- tool(
321
- "read_memory",
322
- "Read all saved memories. Use this to check what you already know before saving duplicates, or to recall context about the owner, past incidents, preferences, etc.",
323
- {},
324
- async () => ({
325
- content: [{ type: "text" as const, text: handlers.readMemory() }],
326
- }),
327
- ),
328
- tool(
329
- "add_memory",
330
- "Save a concise factual memory for future reference. Call this when the user explicitly asks you to remember something, or when a correction needs an immediate durable record. For observations you notice on your own during a session, let the post-session consolidator handle it via staging.md — don't preemptively save here. RULES: Max 300 chars. One insight per entry. NO raw logs, NO transcripts, NO status dumps.",
331
- {
332
- entry: z.string().max(300).describe("A single concise insight (max 300 chars, no raw logs or transcripts)"),
333
- },
334
- async (args) => ({
335
- content: [{ type: "text" as const, text: handlers.addMemory(args.entry) }],
336
- }),
337
- ),
338
- tool(
339
- "list_agents",
340
- "List all available agents. Agents are role/domain specialists that can be delegated to via the Agent tool or referenced by jobs.",
341
- {},
342
- async () => ({
343
- content: [{ type: "text" as const, text: handlers.listAgents() }],
344
- }),
345
- ),
346
- tool(
347
- "list_employees",
348
- "List all employees with their role, project, status, and model. Employees are persistent co-founders/team members scoped to projects.",
349
- {},
350
- async () => ({
351
- content: [{ type: "text" as const, text: handlers.listEmployees() }],
352
- }),
353
- ),
354
- tool(
355
- "place_call",
356
- "Place an outbound phone call. Nia dials the number, introduces herself, and pursues the stated goal. Use for appointments, vendor follow-ups, scheduled standup calls to the owner, or anything that's faster by voice than by message.",
357
- {
358
- number: z.string().describe("E.164 phone number to dial (e.g. +13025551234)."),
359
- goal: z
360
- .string()
361
- .describe(
362
- "What this call should accomplish, in plain English. Seeded into the voice agent's instructions.",
363
- ),
364
- context: z
365
- .string()
366
- .optional()
367
- .describe("Extra background to seed the call (calendar dump, prior notes, etc.)."),
368
- max_minutes: z.number().optional().describe("Hard cap on call duration in minutes (default 10, max 30)."),
369
- voice: z.string().optional().describe("Override the default realtime voice for this call."),
370
- },
371
- async (args) => ({
372
- content: [{ type: "text" as const, text: await handlers.placeCall(args) }],
373
- }),
374
- ),
375
- ],
18
+ ),
376
19
  });
377
20
  }
@@ -0,0 +1,258 @@
1
+ import { z } from "zod";
2
+ import * as handlers from "./index";
3
+ import type { NiaTool } from "./types";
4
+
5
+ export type { NiaTool };
6
+
7
+ /**
8
+ * One declarative tool table, consumed by both transports:
9
+ * - the in-process Claude SDK server (`createNiaMcpServer`)
10
+ * - the loopback HTTP MCP endpoint that the CLI backends (Codex/Gemini) connect to
11
+ *
12
+ * Keeping a single source of truth is what makes "one tool table, two transports"
13
+ * DRY. Handlers live in the domain modules under `src/mcp/tools/*` and are
14
+ * untouched; only `send_message` reads the per-run `McpSourceContext`.
15
+ */
16
+
17
+ export const NIA_TOOLS: NiaTool[] = [
18
+ {
19
+ name: "list_jobs",
20
+ description: "List all scheduled jobs with status and next run time",
21
+ schema: {},
22
+ handler: () => handlers.listJobs(),
23
+ },
24
+ {
25
+ name: "add_job",
26
+ description:
27
+ "Create a new scheduled job. Supports cron expressions (0 9 * * *), interval durations (5m, 2h, 1d), or one-time ISO timestamps.",
28
+ schema: {
29
+ name: z.string().describe("Unique job name"),
30
+ schedule: z.string().describe("Cron expression, duration string, or ISO timestamp"),
31
+ prompt: z
32
+ .string()
33
+ .describe(
34
+ "What the job should do. A non-empty ~/.niahere/jobs/<job-name>/prompt.md overrides this database prompt at runtime.",
35
+ ),
36
+ schedule_type: z.enum(["cron", "interval", "once"]).default("cron").describe("Schedule type"),
37
+ always: z.boolean().default(false).describe("If true, runs 24/7 ignoring active hours"),
38
+ agent: z.string().optional().describe("Agent name to use for this job (loads agent's AGENT.md as system prompt)"),
39
+ employee: z
40
+ .string()
41
+ .optional()
42
+ .describe("Employee name to use for this job (loads employee identity, runs in employee's repo)"),
43
+ stateless: z
44
+ .boolean()
45
+ .default(false)
46
+ .describe("If true, disables working memory (no state.md injection or workspace)"),
47
+ model: z
48
+ .string()
49
+ .optional()
50
+ .describe("Model override for this job (e.g. haiku, sonnet, opus). Overrides agent and global model."),
51
+ },
52
+ handler: (args) => handlers.addJob(args),
53
+ },
54
+ {
55
+ name: "update_job",
56
+ description:
57
+ "Update an existing job's schedule, prompt, always flag, agent, employee, model, stateless, or schedule_type. Only pass fields you want to change.",
58
+ schema: {
59
+ name: z.string().describe("Job name to update"),
60
+ schedule: z.string().optional().describe("New schedule (cron expression, interval duration, or ISO timestamp)"),
61
+ prompt: z
62
+ .string()
63
+ .optional()
64
+ .describe("New database prompt. A non-empty ~/.niahere/jobs/<job-name>/prompt.md overrides this at runtime."),
65
+ always: z.boolean().optional().describe("If true, runs 24/7 ignoring active hours"),
66
+ agent: z.string().nullable().optional().describe("Agent name (set null to remove agent)"),
67
+ employee: z.string().nullable().optional().describe("Employee name (set null to remove employee)"),
68
+ model: z.string().nullable().optional().describe("Model override (set null to remove and use default)"),
69
+ stateless: z
70
+ .boolean()
71
+ .optional()
72
+ .describe("If true, disables working memory (no state.md injection or workspace)"),
73
+ schedule_type: z
74
+ .enum(["cron", "interval", "once"])
75
+ .optional()
76
+ .describe("Schedule type (must match the schedule format)"),
77
+ },
78
+ handler: (args) => handlers.updateJob(args),
79
+ },
80
+ {
81
+ name: "remove_job",
82
+ description: "Delete a scheduled job",
83
+ schema: { name: z.string().describe("Job name to remove") },
84
+ handler: (args) => handlers.removeJob(args.name),
85
+ },
86
+ {
87
+ name: "enable_job",
88
+ description: "Enable a disabled job",
89
+ schema: { name: z.string().describe("Job name to enable") },
90
+ handler: (args) => handlers.enableJob(args.name),
91
+ },
92
+ {
93
+ name: "disable_job",
94
+ description: "Disable a job (stops it from running)",
95
+ schema: { name: z.string().describe("Job name to disable") },
96
+ handler: (args) => handlers.disableJob(args.name),
97
+ },
98
+ {
99
+ name: "archive_job",
100
+ description: "Archive a job (out of sight, won't run). Use unarchive_job to bring it back.",
101
+ schema: { name: z.string().describe("Job name to archive") },
102
+ handler: (args) => handlers.archiveJob(args.name),
103
+ },
104
+ {
105
+ name: "unarchive_job",
106
+ description: "Unarchive a job back to disabled state. Use enable_job after to start running it.",
107
+ schema: { name: z.string().describe("Job name to unarchive") },
108
+ handler: (args) => handlers.unarchiveJob(args.name),
109
+ },
110
+ {
111
+ name: "run_job",
112
+ description: "Trigger a job to run immediately on the next scheduler tick",
113
+ schema: { name: z.string().describe("Job name to run now") },
114
+ handler: (args) => handlers.runJobNow(args.name),
115
+ },
116
+ {
117
+ name: "send_message",
118
+ description:
119
+ "Send a message via configured channel. By default sends to the current context (if in a Slack thread, replies there; otherwise DMs the owner). Use target='dm' to force a DM regardless of context, or target='thread' to explicitly reply in the current thread.",
120
+ schema: {
121
+ text: z.string().describe("Message text to send"),
122
+ channel: z.string().optional().describe("Channel name (telegram, slack). Omit to use default."),
123
+ media_path: z.string().optional().describe("Absolute path to a file to send as an attachment (image, document)"),
124
+ target: z
125
+ .enum(["auto", "dm", "thread"])
126
+ .default("auto")
127
+ .describe(
128
+ "Where to send: 'auto' (current context — thread if in one, else DM), 'dm' (always DM the owner), 'thread' (reply in current thread)",
129
+ ),
130
+ },
131
+ handler: (args, ctx) => handlers.sendMessage(args.text, args.channel, args.media_path, ctx, args.target),
132
+ },
133
+ {
134
+ name: "list_messages",
135
+ description: "Read recent chat history",
136
+ schema: {
137
+ limit: z.number().default(20).describe("Number of messages to return"),
138
+ room: z.string().optional().describe("Filter by room name"),
139
+ },
140
+ handler: (args) => handlers.listMessages(args.limit, args.room),
141
+ },
142
+ {
143
+ name: "list_sessions",
144
+ description: "Browse past conversation sessions with previews. Returns session IDs you can pass to read_session.",
145
+ schema: {
146
+ room: z.string().optional().describe("Filter by room name"),
147
+ limit: z.number().default(10).describe("Number of sessions to return"),
148
+ },
149
+ handler: (args) => handlers.listSessions(args.limit, args.room),
150
+ },
151
+ {
152
+ name: "search_messages",
153
+ description:
154
+ "Search across all past messages by keyword. Returns matching messages with session IDs for deeper reading.",
155
+ schema: {
156
+ query: z.string().describe("Text to search for in message content"),
157
+ room: z.string().optional().describe("Filter by room name"),
158
+ limit: z.number().default(20).describe("Max results to return"),
159
+ },
160
+ handler: (args) => handlers.searchMessages(args.query, args.limit, args.room),
161
+ },
162
+ {
163
+ name: "read_session",
164
+ description:
165
+ "Load the full transcript of a specific conversation session. Use list_sessions or search_messages to find session IDs.",
166
+ schema: { session_id: z.string().describe("Session ID to read") },
167
+ handler: (args) => handlers.readSession(args.session_id),
168
+ },
169
+ {
170
+ name: "add_watch_channel",
171
+ description:
172
+ "Add or update a Slack watch channel. Watch channels receive ALL messages (not just @mentions). Behavior is optional — if omitted, loads watches/<channel_name>/behavior.md at runtime. If a single word, it names a different watch dir. If prose (with spaces), treated as inline behavior. Takes effect on next message (hot-reloads).",
173
+ schema: {
174
+ name: z
175
+ .string()
176
+ .describe("Slack channel key as 'channel_id#channel_name', e.g. 'C1234567890#ask-kay-thread-notifications'"),
177
+ behavior: z
178
+ .string()
179
+ .optional()
180
+ .describe(
181
+ "Optional. Omit to load watches/<channel_name>/behavior.md. A single word names a different watch dir. Prose (with spaces) is inline behavior.",
182
+ ),
183
+ },
184
+ handler: (args) => handlers.addWatchChannel(args.name, args.behavior),
185
+ },
186
+ {
187
+ name: "remove_watch_channel",
188
+ description: "Remove a Slack watch channel. Takes effect on next message (hot-reloads).",
189
+ schema: {
190
+ name: z.string().describe("Slack channel key to stop watching (e.g. 'C1234567890#ask-kay-thread-notifications')"),
191
+ },
192
+ handler: (args) => handlers.removeWatchChannel(args.name),
193
+ },
194
+ {
195
+ name: "enable_watch_channel",
196
+ description: "Enable a disabled Slack watch channel. Takes effect on next message (hot-reloads).",
197
+ schema: { name: z.string().describe("Slack channel key to enable") },
198
+ handler: (args) => handlers.enableWatchChannel(args.name),
199
+ },
200
+ {
201
+ name: "disable_watch_channel",
202
+ description: "Disable a Slack watch channel without removing it. Takes effect on next message (hot-reloads).",
203
+ schema: { name: z.string().describe("Slack channel key to disable") },
204
+ handler: (args) => handlers.disableWatchChannel(args.name),
205
+ },
206
+ {
207
+ name: "add_rule",
208
+ description:
209
+ "Add a behavioral rule. Rules are loaded into every session and take effect without restart. Use for 'from now on' / 'always' / 'never' type instructions.",
210
+ schema: { rule: z.string().describe("The rule to add (e.g. 'stamp updates: 1-2 lines max, no preamble')") },
211
+ handler: (args) => handlers.addRule(args.rule),
212
+ },
213
+ {
214
+ name: "read_memory",
215
+ description:
216
+ "Read all saved memories. Use this to check what you already know before saving duplicates, or to recall context about the owner, past incidents, preferences, etc.",
217
+ schema: {},
218
+ handler: () => handlers.readMemory(),
219
+ },
220
+ {
221
+ name: "add_memory",
222
+ description:
223
+ "Save a concise factual memory for future reference. Call this when the user explicitly asks you to remember something, or when a correction needs an immediate durable record. For observations you notice on your own during a session, let the post-session consolidator handle it via staging.md — don't preemptively save here. RULES: Max 300 chars. One insight per entry. NO raw logs, NO transcripts, NO status dumps.",
224
+ schema: {
225
+ entry: z.string().max(300).describe("A single concise insight (max 300 chars, no raw logs or transcripts)"),
226
+ },
227
+ handler: (args) => handlers.addMemory(args.entry),
228
+ },
229
+ {
230
+ name: "list_agents",
231
+ description:
232
+ "List all available agents. Agents are role/domain specialists that can be delegated to via the Agent tool or referenced by jobs.",
233
+ schema: {},
234
+ handler: () => handlers.listAgents(),
235
+ },
236
+ {
237
+ name: "list_employees",
238
+ description:
239
+ "List all employees with their role, project, status, and model. Employees are persistent co-founders/team members scoped to projects.",
240
+ schema: {},
241
+ handler: () => handlers.listEmployees(),
242
+ },
243
+ {
244
+ name: "place_call",
245
+ description:
246
+ "Place an outbound phone call. Nia dials the number, introduces herself, and pursues the stated goal. Use for appointments, vendor follow-ups, scheduled standup calls to the owner, or anything that's faster by voice than by message.",
247
+ schema: {
248
+ number: z.string().describe("E.164 phone number to dial (e.g. +13025551234)."),
249
+ goal: z
250
+ .string()
251
+ .describe("What this call should accomplish, in plain English. Seeded into the voice agent's instructions."),
252
+ context: z.string().optional().describe("Extra background to seed the call (calendar dump, prior notes, etc.)."),
253
+ max_minutes: z.number().optional().describe("Hard cap on call duration in minutes (default 10, max 30)."),
254
+ voice: z.string().optional().describe("Override the default realtime voice for this call."),
255
+ },
256
+ handler: (args) => handlers.placeCall(args),
257
+ },
258
+ ];
@@ -0,0 +1,16 @@
1
+ import type { z } from "zod";
2
+ import type { McpSourceContext } from "../index";
3
+
4
+ /**
5
+ * Shape of one Nia tool. Kept in a leaf module (no handler imports) so both the
6
+ * tool table and the loopback MCP endpoint can reference the type without
7
+ * pulling the handler → scheduler → runner → agent chain into a cycle.
8
+ */
9
+ export interface NiaTool {
10
+ name: string;
11
+ description: string;
12
+ /** A zod raw shape (the object of field schemas), as the SDK `tool()` expects. */
13
+ schema: z.ZodRawShape;
14
+ /** Returns the user-facing text result. `ctx` is the frozen per-run routing identity. */
15
+ handler: (args: any, ctx?: McpSourceContext) => Promise<string> | string;
16
+ }
@@ -99,9 +99,15 @@ export interface SessionFinalizationConfig {
99
99
  summaries: boolean;
100
100
  }
101
101
 
102
+ /** A coding-agent backend Nia can run on. */
103
+ export type BackendName = "claude" | "codex" | "gemini";
104
+
102
105
  export interface Config {
103
106
  model: string;
104
- runner: "claude" | "codex";
107
+ /** The primary backend for jobs and chat. */
108
+ runner: BackendName;
109
+ /** Ordered fallback backends, tried when the primary is provider-down. */
110
+ fallback: BackendName[];
105
111
  timezone: string;
106
112
  activeHours: { start: string; end: string };
107
113
  database_url: string;