diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts
index 663dcd4..57f151d 100644
--- a/container/agent-runner/src/destinations.ts
+++ b/container/agent-runner/src/destinations.ts
@@ -73,6 +73,22 @@ export function buildSystemPromptAddendum(): string {
].join('\n');
}
+ // Single-destination shortcut: the agent just writes its response normally.
+ // No wrapping needed. This preserves the simple case (one user, one channel).
+ if (cache.length === 1) {
+ const d = cache[0];
+ const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
+ return [
+ '## Sending messages',
+ '',
+ `Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`,
+ '',
+ 'To mark something as scratchpad (logged but not sent), wrap it in `...`.',
+ '',
+ 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.',
+ ].join('\n');
+ }
+
const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', ''];
for (const d of cache) {
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts
index d36b029..0180b72 100644
--- a/container/agent-runner/src/mcp-tools/core.ts
+++ b/container/agent-runner/src/mcp-tools/core.ts
@@ -35,37 +35,52 @@ function destinationList(): string {
return all.map((d) => d.name).join(', ');
}
+/**
+ * Resolve a destination name to routing fields.
+ * If `to` is omitted and the agent has exactly one destination, that one is used.
+ * With multiple destinations, omitting `to` is an error.
+ */
function resolveRouting(
- to: string,
-): { channel_type: string; platform_id: string } | { error: string } {
- const dest = findByName(to);
- if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` };
- if (dest.type === 'channel') {
- return { channel_type: dest.channelType!, platform_id: dest.platformId! };
+ to: string | undefined,
+): { channel_type: string; platform_id: string; resolvedName: string } | { error: string } {
+ let name = to;
+ if (!name) {
+ const all = getAllDestinations();
+ if (all.length === 0) return { error: 'No destinations configured.' };
+ if (all.length > 1) {
+ return {
+ error: `You have multiple destinations — specify "to". Options: ${all.map((d) => d.name).join(', ')}`,
+ };
+ }
+ name = all[0].name;
}
- return { channel_type: 'agent', platform_id: dest.agentGroupId! };
+ const dest = findByName(name);
+ if (!dest) return { error: `Unknown destination "${name}". Known: ${destinationList()}` };
+ if (dest.type === 'channel') {
+ return { channel_type: dest.channelType!, platform_id: dest.platformId!, resolvedName: name };
+ }
+ return { channel_type: 'agent', platform_id: dest.agentGroupId!, resolvedName: name };
}
export const sendMessage: McpToolDefinition = {
tool: {
name: 'send_message',
description:
- 'Send a message to a named destination. Use destination names from your system prompt (not raw IDs).',
+ 'Send a message to a named destination. If you have only one destination, you can omit `to`.',
inputSchema: {
type: 'object' as const,
properties: {
- to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1")' },
+ to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' },
text: { type: 'string', description: 'Message content' },
},
- required: ['to', 'text'],
+ required: ['text'],
},
},
async handler(args) {
- const to = args.to as string;
const text = args.text as string;
- if (!to || !text) return err('to and text are required');
+ if (!text) return err('text is required');
- const routing = resolveRouting(to);
+ const routing = resolveRouting(args.to as string | undefined);
if ('error' in routing) return err(routing.error);
const id = generateId();
@@ -78,32 +93,31 @@ export const sendMessage: McpToolDefinition = {
content: JSON.stringify({ text }),
});
- log(`send_message: #${seq} → ${to}`);
- return ok(`Message sent to ${to} (id: ${seq})`);
+ log(`send_message: #${seq} → ${routing.resolvedName}`);
+ return ok(`Message sent to ${routing.resolvedName} (id: ${seq})`);
},
};
export const sendFile: McpToolDefinition = {
tool: {
name: 'send_file',
- description: 'Send a file to a named destination.',
+ description: 'Send a file to a named destination. If you have only one destination, you can omit `to`.',
inputSchema: {
type: 'object' as const,
properties: {
- to: { type: 'string', description: 'Destination name' },
+ to: { type: 'string', description: 'Destination name. Optional if you have only one destination.' },
path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' },
text: { type: 'string', description: 'Optional accompanying message' },
filename: { type: 'string', description: 'Display name (default: basename of path)' },
},
- required: ['to', 'path'],
+ required: ['path'],
},
},
async handler(args) {
- const to = args.to as string;
const filePath = args.path as string;
- if (!to || !filePath) return err('to and path are required');
+ if (!filePath) return err('path is required');
- const routing = resolveRouting(to);
+ const routing = resolveRouting(args.to as string | undefined);
if ('error' in routing) return err(routing.error);
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
@@ -125,8 +139,8 @@ export const sendFile: McpToolDefinition = {
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
});
- log(`send_file: ${id} → ${to} (${filename})`);
- return ok(`File sent to ${to} (id: ${id}, filename: ${filename})`);
+ log(`send_file: ${id} → ${routing.resolvedName} (${filename})`);
+ return ok(`File sent to ${routing.resolvedName} (id: ${id}, filename: ${filename})`);
},
};
diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts
index 6b358de..83d0316 100644
--- a/container/agent-runner/src/poll-loop.ts
+++ b/container/agent-runner/src/poll-loop.ts
@@ -1,4 +1,4 @@
-import { findByName } from './destinations.js';
+import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
@@ -296,11 +296,14 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
/**
* Parse the agent's final text for ... blocks
* and dispatch each one to its resolved destination. Text outside of blocks
- * (including ...) is scratchpad — logged but not sent.
+ * (including ...) is normally scratchpad — logged but
+ * not sent.
*
- * If the agent emits zero blocks AND non-empty text, log a warning:
- * the agent produced output with no recipient. That's usually a bug in the
- * agent — the system prompt tells it to wrap user-visible text in blocks.
+ * Single-destination shortcut: if the agent has exactly one configured
+ * destination AND the output contains zero blocks, the entire
+ * cleaned text (with tags stripped) is sent to that destination.
+ * This preserves the simple case of one user on one channel — the agent
+ * doesn't need to know about wrapping syntax at all.
*/
function dispatchResultText(text: string, routing: RoutingContext): void {
const MESSAGE_RE = /([\s\S]*?)<\/message>/g;
@@ -324,18 +327,7 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`);
continue;
}
-
- const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
- const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
- writeMessageOut({
- id: generateId(),
- in_reply_to: routing.inReplyTo,
- kind: 'chat',
- platform_id: platformId,
- channel_type: channelType,
- thread_id: null,
- content: JSON.stringify({ text: body }),
- });
+ sendToDestination(dest, body, routing);
sent++;
}
if (lastIndex < text.length) {
@@ -346,6 +338,17 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
.join('')
.replace(/[\s\S]*?<\/internal>/g, '')
.trim();
+
+ // Single-destination shortcut: the agent wrote plain text and has exactly
+ // one destination. Send the entire cleaned text to it.
+ if (sent === 0 && scratchpad) {
+ const all = getAllDestinations();
+ if (all.length === 1) {
+ sendToDestination(all[0], scratchpad, routing);
+ return;
+ }
+ }
+
if (scratchpad) {
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
}
@@ -355,6 +358,20 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
}
}
+function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
+ const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
+ const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
+ writeMessageOut({
+ id: generateId(),
+ in_reply_to: routing.inReplyTo,
+ kind: 'chat',
+ platform_id: platformId,
+ channel_type: channelType,
+ thread_id: null,
+ content: JSON.stringify({ text: body }),
+ });
+}
+
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md
index c95469e..cc5480f 100644
--- a/groups/global/CLAUDE.md
+++ b/groups/global/CLAUDE.md
@@ -16,24 +16,23 @@ You are Main, a personal assistant. You help with tasks, answer questions, and c
Be concise — every message costs the reader's attention.
-### Named destinations
+### Destinations
-You don't send messages to a "current conversation" — every outbound message goes to an explicitly named destination. The list of destinations available to you is injected into your system prompt at the start of every turn.
-
-**To send a message**, wrap it in a `...` block. You can include multiple blocks in one response to send to multiple destinations. Text outside of `` blocks is scratchpad — logged but never sent anywhere.
+Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `...` block:
```
On my way home, 15 minutes
+kick off the pipeline
```
-Inbound messages are labeled with `from="name"` so you know which destination they came from and can reply by using that same name as `to=`.
+Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name.
### Mid-turn updates
-Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output) — it takes the same `to` destination name. Pace your updates to the length of the work:
+Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
-- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final `` block.
-- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") via `send_message` so the user knows you got the message.
+- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response.
+- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message.
- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
@@ -42,12 +41,12 @@ Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before yo
### Internal thoughts
-If part of your output is internal reasoning rather than something for the reader, wrap it in `` tags — or just leave it as plain text outside any `` block. Both are scratchpad.
+Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `` blocks is also treated as scratchpad. With a single destination, only explicit `` tags are scratchpad; the rest of your response is sent.
```
Compiled all three reports, ready to summarize.
-Here are the key findings from the research…
+Here are the key findings from the research…
```
### Sub-agents and teammates