TL;DR

Second article in the series on building Bloom, a multi-agent coaching agent, in one week. If the first article was about who decides where the conversation goes, this one is about what changes once the agent stops just talking and starts actually acting in the world — in this case, writing real events into Google Calendar via MCP.

  • Reading kept separate from writing: the Planning Specialist’s tools split into reading (checking availability, listing appointments) and writing (proposing time slots, confirming the plan). Writing only fires on explicit user agreement recorded in the conversation — never because the model “thought” it already had enough information.
  • Every loop needs a ceiling: the Planning Specialist’s ReAct loop is capped at 5 iterations per turn, echoing the Coordinator’s 3-delegations-per-turn limit from the previous article. An uncapped loop is first a latency and cost risk, before it’s even a correctness risk — the cap needs to be born in the design, not patched in after someone complains about latency.
  • Control signals shouldn’t travel hidden inside the text: forcing the model to always respond in structured JSON ({"response": "...", "confirmed": true}) measurably stiffened the conversation’s tone. Moving confirmed into its own tool call and leaving the text free fixed it — whenever a response needs to carry both a message for the user and a machine-readable signal, the two should travel on different channels.
  • Latency changes character with loops: a plan confirmation now costs 2 or 3 LLM calls in sequence, which breaks any latency SLA designed for a single call. Streaming fixes the perception (“the assistant is thinking” instead of “the assistant froze”) with little code, and per-turn telemetry stops making sense without per-call granularity.
  • Minimal scope and sandboxed integrations: the OAuth integration only requests calendar.events, never more, even when the SDK would make it just as easy to ask for more. Google Calendar sits behind a dedicated MCP server, which makes the integration swappable and contains the blast radius of any failure or compromised credential.
  • Resilience without breaking the conversation: a dual-mode connection (SSE with fallback to HTTP POST, then to a local mock store) ensures an unstable external integration becomes, at worst, a technical detail — not a broken conversation for the person on the other end.
  • Minimize data before protecting data: capping the history sent to the LLM at the last 15 messages (with older history compressed locally into summaries) is simultaneously a cost control and a privacy control — less raw user data crossing the network per call.

Deciding in advance who has the authority to act and under exactly which conditions that authority activates — the same code-vs-model measuring stick from the first article, now applied to real action in the world, not just routing.


Making the loop act without losing control

Article 2 of a series on what I learned building a multi-agent coaching agent in one week


I closed the previous article with a question: in every system decision, who’s in charge — the code or the model? I talked about routing, about who decides which specialist a conversation goes to. But routing is only half the story. The other half shows up the moment an agent stops just talking and starts acting — writing to a calendar, confirming an appointment, changing something that exists outside the conversation.

That’s where Bloom’s Planning Specialist comes in. It’s not a chatbot that suggests time slots; it has four real calendar tools, connected via MCP (Model Context Protocol), and can actually create events on someone’s Google Calendar. This article’s question is a direct variation on the last one: how do you give an agent real freedom to act in the world without giving up control over when and how it acts?


Separate read tools from write tools

The first design decision was splitting the Planning Specialist’s four tools into two groups of completely different natures:

  • Read: get_free_busy (availability over the next 7 days, in 30-minute blocks) and list_upcoming (already-scheduled sessions, to avoid time conflicts)

  • Write: propose_sessions (commits to specific time slots, without saving anything yet) and confirm_plan (actually writes to the calendar)

The write tool only fires after an explicit trigger — genuine user agreement, recorded in the conversation transcript. It doesn’t depend on the model “deciding” it has gathered enough information to act. This is one of the cheapest, most general rules I’m carrying out of this project: any agent that writes to a calendar, sends an email, or spends real money needs this separation between looking and acting, with an explicit agreement trigger between the two steps.

Here is how these tools are structured in our planning.agent.ts to separate read operations from write actions:

const PLANNING_TOOLS: ToolDefinition[] = [
  // --- READ TOOLS (No side effects) ---
  {
    name: 'get_free_busy',
    description: 'Get calendar availability for the next 7 days in 30-minute slots. Use this before proposing sessions.',
    parameters: { type: 'object', properties: { week_start: { type: 'string' } } }
  },
  {
    name: 'list_upcoming',
    description: 'List already-scheduled learning sessions to avoid double-booking.',
    parameters: { type: 'object', properties: { limit: { type: 'number' } } }
  },
  // --- WRITE TOOLS (Require user confirmation) ---
  {
    name: 'propose_sessions',
    description: 'Commit to specific session times and present them to the learner. No calendar write yet.',
    parameters: {
      type: 'object',
      properties: {
        sessions: {
          type: 'array',
          items: {
            type: 'object',
            properties: {
              title: { type: 'string' },
              scheduled_at: { type: 'string' },
              duration_minutes: { type: 'number' }
            },
            required: ['title', 'scheduled_at', 'duration_minutes']
          }
        }
      },
      required: ['sessions']
    }
  },
  {
    name: 'confirm_plan',
    description: 'Write confirmed sessions to the calendar. Only call this after the learner has explicitly agreed.',
    parameters: {
      type: 'object',
      properties: {
        weekly_goal: { type: 'string' },
        sessions: {
          type: 'array',
          items: {
            type: 'object',
            properties: {
              title: { type: 'string' },
              scheduled_at: { type: 'string' },
              duration_minutes: { type: 'number' }
            },
            required: ['title', 'scheduled_at', 'duration_minutes']
          }
        }
      },
      required: ['weekly_goal', 'sessions']
    }
  }
];

Every loop needs a ceiling

The Planning Specialist doesn’t call its four tools however it pleases — it runs a ReAct loop (reason, act, observe, repeat) capped at 5 iterations per turn. This echoes the Coordinator’s decision from the previous article, which limits delegations to 3 per turn.

An uncapped loop is, first and foremost, a latency and cost risk — well before it’s a correctness risk. An agent can get stuck in “let me check again” cycles, and the user pays for that indecision in seconds of waiting and tokens spent. The practical rule that stuck: every agentic loop gets a maximum iteration count from the design stage, not as a patch applied after someone complains about latency.

In planning.agent.ts, the ReAct loop is capped at a maximum of 5 iterations to protect against runaway cycles:

// Inside PlanningAgent.processTurn:
for (let i = 0; i < 5; i++) {
  const result = await llmService.generateWithTools(systemPrompt, messages, PLANNING_TOOLS);

  // Text means the agent completed its reasoning and responded to the user
  if (result.type === 'text') {
    return { response: result.content, confirmed: false };
  }

  // Write operation terminates the loop and commits the plan
  if (result.name === 'confirm_plan') {
    const rawSessions = result.args.sessions as RawSession[];
    const weeklyGoal = (result.args.weekly_goal as string) || `Complete study for ${goal}`;

    const sessionsWithIds = await Promise.all(
      rawSessions.map(async s => {
        const scheduledAt = new Date(s.scheduled_at);
        const { eventId, synced } = await calendarService.createEvent(s.title, scheduledAt, s.duration_minutes);
        return {
          topic: s.title,
          duration_minutes: s.duration_minutes,
          scheduled_at: scheduledAt,
          format: 'practice' as const,
          effort_level: 'moderate' as const,
          calendar_event_id: eventId,
          synced
        };
      })
    );

    return {
      response: `Your plan is confirmed — ${sessionsWithIds.length} sessions added to your calendar.`,
      confirmed: true,
      proposedPlan: { weekly_goal: weeklyGoal, sessions: sessionsWithIds }
    };
  }

  // Execute helper read tools (get_free_busy, list_upcoming, propose_sessions)
  const toolResult = await this.executeTool(result.name, result.args, goal);

  // Push tool call & observation back into context
  messages.push({
    role: 'assistant',
    content: '',
    toolCall: { id: result.id, name: result.name, args: result.args }
  });
  messages.push({
    role: 'tool',
    content: typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult),
    toolCallId: result.id,
    toolName: result.name
  });
}

Control signals shouldn’t travel hidden inside the text

An early version of Bloom forced the model to always respond in structured JSON: {"response": "...", "confirmed": true}. The idea seemed reasonable — a single output format carrying both the message for the user and an internal control signal (whether the plan was confirmed or not).

The problem showed up in tone. Forcing the model to produce that JSON every turn measurably stiffened the conversation — the text became drier, more bureaucratic, less natural. (I’ll come back to this exact point in more depth in the next article, because it’s actually a lesson about prompting, not just about tools.)

The fix was moving confirmed into its own tool call — confirm_plan — and leaving the text channel free to be just conversation.

The lesson I took from this stage: whenever a response needs to carry both a message for the user and a machine-readable signal at the same time, split the two into different channels. A tool call for the signal, free text for the speech. Mixing structure into speech has a tone cost that’s easy to underestimate until you measure it.

Latency changes character once you add loops

A latency SLA designed for a single-call system simply dies the moment a plan confirmation starts costing 2 or 3 LLM calls in sequence. That’s not an infrastructure detail — it’s a direct, predictable consequence of introducing a tool loop.

The practical fix was streaming: making the latency of a multi-step process feel like “the assistant is thinking” instead of “the assistant froze.” With little code, it solves a problem that, without it, looks far worse than it is.

This also pushes a consequence into the observability layer: once the number of calls per turn becomes a variable — because it depends on how many loop iterations were needed — a single “latency per turn” metric stops making sense without per-call granularity. I’ll come back to this in more detail in the last article of the series, when I talk about telemetry.

Minimal permission scope, even when the SDK makes it easy to ask for more

Bloom’s OAuth integration with Google Calendar is strictly scoped to calendar.events — never contacts, email, or drive. The SDK would make requesting a broader scope just as easy; the discipline of asking only for the minimum necessary is a deliberate choice, not a limitation imposed by the tool.

This is one of those rules that sound obvious stated like this, and that get systematically ignored in practice, because the path of least resistance when integrating an external provider is always to request the most generic scope available “just in case” — which, in practice, is the opposite of safety.

Sandbox external integrations behind a protocol

Instead of embedding the Google Calendar SDK and its credentials directly in the application, Bloom sandboxes that integration behind a dedicated MCP server. That brings two distinct benefits:

  1. The integration becomes swappable — Google Calendar today, any CalDAV server tomorrow, without rewriting the whole application.

  2. A compromised or buggy integration has no direct access to the rest of the application’s credentials. The blast radius of a failure stays contained inside the MCP server itself.

We can visualize this sandbox separation with the following diagram:

graph LR
    classDef app fill:#4f46e5,stroke:#312e81,stroke-width:2px,color:#ffffff;
    classDef mcp fill:#0ea5e9,stroke:#0369a1,stroke-width:1px,color:#ffffff;
    classDef api fill:#10b981,stroke:#047857,stroke-width:1px,color:#ffffff;

    Backend(Bloom Backend Service):::app <--> |SSE / JSON-RPC protocol| CalendarMCP(Google Calendar MCP Server):::mcp
    CalendarMCP <--> |HTTPS OAuth / scoped: calendar.events| GoogleAPI(Google Calendar API):::api

Resilience to integration failures without breaking the conversation

External integrations go down. The design question isn’t “how do I prevent this” — it’s “what happens to the conversation when this happens.” Bloom uses a dual-mode connection: SSE as the first option, falling back to HTTP POST after a 2-second timeout. If the calendar server is completely offline, there’s still a fallback to a local mock store.

The practical goal is for the conversation to stay smooth even with an unstable integration. A technical network error doesn’t need to become an experience error for the person on the other end trying to schedule a study session.

Here is how CalendarService implements this resilience in calendar.service.ts using Promise.race for handshake timeouts and fallback channels:

// Connects to the MCP server with a strict 2-second timeout
private async getMcpClient(): Promise<any | null> {
  // ...
  try {
    const transport = new SSEClientTransport(new URL(sseUrl));
    const client = new Client({ name: 'bloom-coaching-backend', version: '1.0.0' }, { capabilities: {} });

    const connectPromise = client.connect(transport);
    const timeoutPromise = new Promise((_, reject) =>
      setTimeout(() => reject(new Error('MCP handshake timeout after 2000ms')), 2000)
    );

    await Promise.race([connectPromise, timeoutPromise]);
    this.client = client;
    return this.client;
  } catch (err) {
    console.warn(`[CalendarService] Failed to initialize MCP client, falling back to mock:`, err);
    this.client = null;
    return null;
  }
}

// Tries MCP, falls back to direct HTTP POST, and finally to local memory store
public async createEvent(title: string, start: Date, durationMinutes: number) {
  let googleEventId: string | null = null;
  let synced = false;

  if (sseUrl) {
    const client = await this.getMcpClient();
    if (client) {
      try {
        const toolResult = await client.callTool({
          name: 'create_calendar_event',
          arguments: { summary: title, start: start.toISOString(), end: end.toISOString() }
        });
        googleEventId = JSON.parse(toolResult.content[0].text)?.event?.id ?? null;
        synced = true;
      } catch (err) {
        console.warn(`[MCP Calendar] Failed to call external calendar tool, trying POST fallback:`, err);
      }
    }

    // Fallback to direct HTTP POST if MCP connection failed
    if (!synced) {
      try {
        const res = await fetch(sseUrl, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            name: 'create_event',
            parameters: { summary: title, start: start.toISOString(), end: end.toISOString() }
          })
        });
        if (res.ok) {
          const data = await res.json();
          googleEventId = data?.value?.event?.id ?? null;
          synced = true;
        }
      } catch (postErr) {
        console.warn('[MCP Calendar] Direct HTTP POST fallback failed:', postErr);
      }
    }
  }

  // Always persist in local mock memory store so conversation flow is never broken
  const eventId = googleEventId ?? crypto.randomUUID();
  CalendarService.mockEvents.set(eventId, { id: eventId, title, start, end });
  return { eventId, synced };
}

Minimize data crossing the network, not just protect what already crossed

Finally, a control that serves two purposes at once: the conversation history sent to the LLM API is capped at the last 15 messages. Older history is compressed locally into summaries and never resent in bulk to the model.

It’s simultaneously a cost control (fewer tokens per call) and a privacy control — less raw user data crossing the network on every call. It’s a good example of a decision that looks like it’s only about performance until you realize it’s also, quietly, about data protection.


The thread that keeps repeating

Separating reading from writing, capping loops, splitting signal channels, scoping permissions to the minimum, sandboxing integrations, minimizing the data that travels — these are all concrete variations of the same principle from the first article in this series: deciding in advance who has the authority to act, and under exactly which conditions that authority activates.

One thing was left dangling along the way, deliberately not explored in depth: the control JSON embedded in the text wasn’t just an architecture problem — it measurably changed the model’s tone. That observation pulls directly into the next article in the series, where the prompt layer — not the tools layer — becomes the central subject.