> ## Documentation Index
> Fetch the complete documentation index at: https://docs.guild.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Maya: a Calendar chief of staff

> Turn today's Google Calendar into one operating brief, with conflicts flagged and source status stated plainly instead of invented.

Maya is a TypeScript agent that reads a bounded window of your Google Calendar, normalizes the events, detects overlaps, and asks one LLM call to write a short executive summary. It posts the brief to Slack, or returns the rendered text so another tool can deliver it.

```text theme={null}
schedule or request -> bounded Calendar read -> normalize + detect conflicts
                                              -> one grounded synthesis
                                              -> Slack post or returned text
```

<Card title="dkountanis~maya-chief-of-staff" icon="robot" href="https://app.guild.ai/hub/agents/dkountanis~maya-chief-of-staff">
  View the agent and its source on the Agent Hub.
</Card>

## At a glance

* **Agent type:** auto-managed [TypeScript agent](/guide/coded-agents)
* **Integrations:** [Google Calendar](/integrations/google-calendar) (read-only) and, for direct delivery, [Slack](/integrations/slack)
* **Runs from:** a [schedule trigger](/platform/schedule-triggers), or a session with JSON input
* **LLM calls:** one per brief

**What it does:**

* Reads a window that starts now, runs for a set number of hours, and never crosses local midnight in your timezone
* Keeps at most 20 source events, 12 events in the brief, and 6 conflicts
* States when the Calendar was only partly available, or unavailable, instead of filling the gap
* Gives each day's brief a stable ID, and checks Slack for it before posting, so a retry doesn't post twice
* Returns the brief instead of posting it when called with `delivery: "return"`
* Has no tools that create or change Calendar events

## How it works

<Steps>
  <Step title="Resolve a safe window">
    Workspace variables set the timezone, the Calendar, the look-ahead period, the Slack channel, and the Slack bot's identity. The input can't override the channel.
  </Step>

  <Step title="Gather bounded evidence">
    Maya reads the configured Calendar once. Synchronous helpers normalize the timestamps and detect overlaps. Event descriptions, URLs, and provider IDs never reach the LLM prompt.
  </Step>

  <Step title="Synthesize from normalized facts">
    The LLM receives a capped packet of normalized events and writes only the executive summary. Deterministic code renders the headings, event lines, conflicts, source status, and the brief's ID marker.
  </Step>

  <Step title="Deliver or hand off">
    In direct mode, Maya reads one page of the Slack channel's history first. If it finds its own message with today's marker, it skips the post. In return mode, it makes no Slack calls and returns the rendered text.
  </Step>
</Steps>

## Build the agent

### Define the input and output

Configuration lives in [workspace variables](/platform/workspace-variables). The input chooses how Maya was invoked and how to deliver, but can't supply a Calendar ID or a Slack destination:

```ts theme={null}
import { agent, type Task } from "@guildai/agents-sdk";
import { z } from "zod";

const inputSchema = z.object({
  type: z.enum(["text", "timer"]).optional(),
  text: z.string().max(8_000).optional(),
  runAt: z.string().datetime().optional(),
  deliveryKey: z
    .string()
    .min(1)
    .max(64)
    .regex(/^[A-Za-z0-9._-]+$/)
    .optional(),
  delivery: z.enum(["slack", "return"]).optional(),
});

const outputSchema = z.object({
  status: z.enum([
    "completed",
    "partial",
    "failed",
    "skipped",
    "deduplicated",
  ]),
  text: z.string().max(6_000),
  brief: briefSchema,
  notification: z.object({
    subject: z.string().max(120),
    text: z.string().max(6_000),
  }),
  deliveryReceipt: deliveryReceiptSchema,
  deliveryHandoff: z.object({
    kind: z.literal("notification"),
    channel: z.literal("slack"),
    configuredChannel: z.string().max(100),
    delivered: z.boolean(),
    payload: z.object({
      subject: z.string().max(120),
      text: z.string().max(6_000),
    }),
    receipt: deliveryReceiptSchema,
  }),
  warnings: z.array(z.string().max(120)).max(8),
  message: z.string().max(200),
});
```

The full source also accepts a bounded Slack event envelope, for correlation when another Slack app invokes Maya.

### Register the Calendar and Slack tools

Only the Calendar read comes from a service package. Each Slack operation is registered on its own with `guildServiceTool`:

```ts theme={null}
import {
  consoleTools,
  guildServiceTool,
  pick,
} from "@guildai/agents-sdk";
import { GoogleCalendarOauthTools } from "@guildai-services/guildlabs~google-calendar-oauth";

const tools = {
  ...pick(GoogleCalendarOauthTools, [
    "google_calendar_oauth_events_list",
  ]),
  slack_conversations_history: guildServiceTool("slack", {
    description:
      "Read one bounded channel page to suppress sequential duplicate briefs.",
    inputSchema: slackHistoryInputSchema,
    outputSchema: slackHistoryOutputSchema,
  }),
  slack_chat_post_message: guildServiceTool("slack", {
    description: "Post one brief to the configured Slack channel.",
    inputSchema: slackPostMessageInputSchema,
    outputSchema: slackPostMessageOutputSchema,
  }),
  ...consoleTools,
};
```

The published source also rewrites the Calendar tool's service name to `google-calendar-oauth`, the integration's name in the live catalog. That's needed only because the `1.0.0` package it pins was generated before a codegen fix. A package version generated after the fix already carries the right name and doesn't need the rewrite.

### Resolve the local-day window

The look-ahead period never crosses local midnight:

```ts theme={null}
export function resolveBriefWindow(
  runAtInput: string | undefined,
  timezone: string,
  lookAheadHours: number,
): BriefWindow {
  const runAt = runAtInput ? new Date(runAtInput) : new Date();
  if (Number.isNaN(runAt.getTime())) {
    throw new Error("runAt must be a valid ISO timestamp.");
  }

  const localDate = dateInTimezone(runAt, timezone);
  const endOfLocalDay = zonedMidnight(addDays(localDate, 1), timezone);
  const lookAheadEnd = new Date(runAt.getTime() + lookAheadHours * 3_600_000);
  const end =
    lookAheadEnd.getTime() < endOfLocalDay.getTime()
      ? lookAheadEnd
      : endOfLocalDay;

  return {
    start: runAt.toISOString(),
    end: end.toISOString(),
    localDate,
  };
}
```

### Detect conflicts without the LLM

Conflict detection is deterministic and capped. Because the events are sorted, the inner loop stops as soon as later events can no longer overlap:

```ts theme={null}
export function detectCalendarConflicts(
  events: CalendarEvent[],
): ConflictDetection {
  const timed = events
    .filter((event) => !event.allDay)
    .map((event) => ({
      event,
      startMs: Date.parse(event.start),
      endMs: Date.parse(event.end),
    }))
    .filter((entry) => entry.endMs > entry.startMs)
    .sort((left, right) => left.startMs - right.startMs);

  const conflicts: CalendarConflict[] = [];
  let totalDetected = 0;

  for (let leftIndex = 0; leftIndex < timed.length; leftIndex += 1) {
    const left = timed[leftIndex];
    for (let rightIndex = leftIndex + 1; rightIndex < timed.length; rightIndex += 1) {
      const right = timed[rightIndex];
      if (right.startMs >= left.endMs) break;

      const overlapMs =
        Math.min(left.endMs, right.endMs) -
        Math.max(left.startMs, right.startMs);
      if (overlapMs <= 0) continue;

      totalDetected += 1;
      if (conflicts.length < 6) {
        conflicts.push({
          firstEventId: left.event.id,
          secondEventId: right.event.id,
          firstTitle: left.event.title,
          secondTitle: right.event.title,
          overlapMinutes: Math.max(1, Math.round(overlapMs / 60_000)),
        });
      }
    }
  }

  return {
    conflicts,
    summary: {
      totalDetected,
      retained: conflicts.length,
      truncated: totalDetected > conflicts.length,
    },
  };
}
```

### Gather, synthesize, and deliver

The I/O stays thin. Normalization and rendering are synchronous helpers kept outside the compiled state machine, as [Coded agents](/guide/coded-agents#keep-heavy-work-in-synchronous-helpers-not-async-ones) recommends:

```ts theme={null}
async function run(input: Input, task: Task<Tools>): Promise<Output> {
  const settings = resolveSettings(task.env);
  const window = resolveBriefWindow(
    input.runAt,
    settings.timezone,
    settings.lookAheadHours,
  );

  const calendarRequest = {
    calendar_id: settings.calendarId,
    time_min: window.start,
    time_max: window.end,
    time_zone: settings.timezone,
    single_events: true,
    order_by: "startTime" as const,
    max_results: 20,
    show_deleted: false,
  };

  const settled = await task.gatherSettled([
    task.tools.google_calendar_oauth_events_list(calendarRequest),
  ]);
  const sources = normalizeSettledSources(settled[0]);
  const conflictDetection = detectCalendarConflicts(sources.calendarEvents);

  const normalizedEvidence = {
    generatedAt: new Date().toISOString(),
    timezone: settings.timezone,
    window,
    calendarEvents: sources.calendarEvents,
    conflicts: conflictDetection.conflicts,
    conflictSummary: conflictDetection.summary,
    sources: { calendar: sources.calendarStatus },
  };

  const fallback = fallbackSynthesis(
    sources.calendarEvents,
    conflictDetection.summary,
    sources.calendarStatus,
  );
  const generated =
    sources.calendarStatus.status === "unavailable"
      ? undefined
      : await task.llm.generateText({
          system: systemPrompt,
          prompt: buildSynthesisPrompt(normalizedEvidence),
        });
  const synthesis = chooseSynthesis(generated?.text, fallback);
  const brief = { ...normalizedEvidence, synthesis: synthesis.text };
  const briefId = buildBriefId(
    settings,
    window.localDate,
    input.deliveryKey,
  );
  const text = appendBriefMarker(
    renderSlackBrief(brief),
    briefId,
  );
  let deliveryReceipt: DeliveryReceipt;
  if (input.delivery === "return") {
    deliveryReceipt = {
      attempted: false,
      delivered: false,
      deliveryStatus: "skipped",
      channel: settings.slackChannel,
      messageTs: null,
      briefId,
    };
  } else {
    // The complete implementation performs the authenticated marker
    // preflight shown below before this post.
    const posted = await task.tools.slack_chat_post_message({
      channel: settings.slackChannel,
      text,
      mrkdwn: true,
      link_names: false,
      unfurl_links: false,
      unfurl_media: false,
    });
    deliveryReceipt = buildDeliveryReceipt(settings.slackChannel, briefId, {
      status: "fulfilled",
      value: posted,
    });
  }

  const notification = {
    subject: `Maya daily brief — ${window.localDate}`,
    text,
  };
  const status = resolveRunStatus(
    overallSourceStatus(sources.calendarStatus),
    synthesis.usedFallback,
    deliveryReceipt,
  );

  return {
    status,
    text,
    brief,
    notification,
    deliveryReceipt,
    deliveryHandoff: {
      kind: "notification",
      channel: "slack",
      configuredChannel: settings.slackChannel,
      delivered: deliveryReceipt.delivered,
      payload: notification,
      receipt: deliveryReceipt,
    },
    warnings: sources.warnings,
    message:
      input.delivery === "return"
        ? "Daily brief returned for adapter delivery."
        : "Daily brief posted to the configured Slack channel.",
  };
}
```

### Fail closed during the duplicate check

Don't treat incomplete Slack history as "marker absent." A history page that's malformed, or that has more messages beyond it, isn't proof that today's brief hasn't been posted:

```ts theme={null}
export function normalizeSlackHistory(
  payload: unknown,
  briefId: string,
  expectedBotUserId: string,
): SlackHistoryCheck {
  const root = recordOf(payload);
  if (
    root?.ok !== true ||
    !Array.isArray(root.messages) ||
    typeof root.has_more !== "boolean"
  ) {
    return { status: "invalid", checkedCount: 0, existingTs: null };
  }

  const messages = root.messages.slice(0, 20);
  for (const value of messages) {
    const message = minimizeSlackHistoryMessage(value);
    if (
      message.user === expectedBotUserId &&
      message.botId &&
      message.text &&
      hasSingleFinalBriefMarker(message.text, briefId)
    ) {
      return {
        status: "existing",
        checkedCount: messages.length,
        existingTs: message.ts ?? null,
      };
    }
  }

  return {
    status: root.has_more ? "partial" : "absent",
    checkedCount: messages.length,
    existingTs: null,
  };
}
```

## Set it up

<Steps>
  <Step title="Add the agent">
    Open [`dkountanis~maya-chief-of-staff`](https://app.guild.ai/hub/agents/dkountanis~maya-chief-of-staff) on the Agent Hub and add it to a workspace.
  </Step>

  <Step title="Connect Google Calendar">
    Connect a Google Calendar [credential](/platform/credentials), and use a [credential policy](/platform/credential-policies) to limit it to reading events.
  </Step>

  <Step title="Set workspace variables">
    Add these [workspace variables](/platform/workspace-variables), using your own values:

    ```text theme={null}
    MAYA_TIMEZONE=Europe/Athens
    MAYA_CALENDAR_ID=primary
    MAYA_LOOKAHEAD_HOURS=24
    MAYA_SLACK_CHANNEL=C0123456789
    MAYA_SLACK_BOT_USER_ID=U0123456789
    ```

    `MAYA_SLACK_BOT_USER_ID` is the Slack user that posts the brief. Maya looks for its own earlier message when it checks for a duplicate.
  </Step>

  <Step title="Connect Slack">
    For direct delivery, connect a [Slack](/integrations/slack) credential and limit it to `conversations.history` and `chat.postMessage`. Skip this if you only use return mode.
  </Step>

  <Step title="Schedule it">
    Create a [schedule trigger](/platform/schedule-triggers) that runs Maya every weekday morning in your timezone:

    ```bash theme={null}
    guild trigger create \
      --workspace "<owner>~<workspace>" \
      --type time \
      --frequency CRON \
      --cron-expression "0 8 * * 1-5" \
      --cron-timezone Europe/Athens \
      --agent dkountanis~maya-chief-of-staff \
      --input '{"type": "timer"}'
    ```
  </Step>
</Steps>

To run Maya once without a schedule, start a session with it and [send this input](/platform/sessions#sending-structured-input):

```json theme={null}
{
  "type": "timer"
}
```

To have another tool deliver the brief instead, call Maya with return mode and post the `text` it returns:

```json theme={null}
{
  "type": "text",
  "text": "Create today's operating brief.",
  "delivery": "return"
}
```

A branded Slack bot named Maya is an example of that pattern: it forwards the request with `delivery: "return"` and posts the returned `text` itself. That bot runs on its own host with its own credentials. The agent doesn't include it.

## Expected result

The output includes `status`, the rendered `text`, the normalized `brief`, a `notification`, a `deliveryReceipt`, a `deliveryHandoff`, and up to eight `warnings`:

```json theme={null}
{
  "status": "completed",
  "text": "*Maya — Daily Brief · 2026-09-16* ...",
  "deliveryReceipt": {
    "attempted": true,
    "delivered": true,
    "deliveryStatus": "posted"
  }
}
```

If the Calendar is unavailable, Maya says so in the brief and returns `failed` without inventing events. In return mode, delivery is `skipped` on purpose, and the brief itself still succeeds.

## Run it locally

From the agent's directory:

```bash theme={null}
pnpm install
pnpm test
pnpm run typecheck
pnpm run build
pnpm run bundle
guild agent test --bundle agent.js.gz
```

## Relevant code

| File | What it holds |
| - | - |
| `agent.ts` | Input and output contracts, tool orchestration, and delivery modes |
| `logic.ts` | Windowing, normalization, conflict detection, markers, and rendering |
| `system-prompt.md` | The evidence-grounded synthesis prompt |
| `logic.test.ts` | Deterministic logic tests |
| `orchestration.test.mjs` | Tool-call and failure-path tests |

## Boundaries

Maya doesn't create or edit Calendar events, read Gmail or Asana, or accept a Slack channel from the caller. The duplicate check stops ordinary retries that run one after another. It isn't a lock, so two runs that start at the same moment can both post.

## Related

<CardGroup cols={2}>
  <Card title="Coded agents" icon="code" href="/guide/coded-agents">
    How TypeScript agents are structured and built.
  </Card>

  <Card title="Schedule triggers" icon="calendar-days" href="/platform/schedule-triggers">
    Run an agent on a recurring schedule.
  </Card>

  <Card title="Workspace variables" icon="sliders" href="/platform/workspace-variables">
    Configure an agent per workspace without changing its code.
  </Card>

  <Card title="Credential policies" icon="shield" href="/platform/credential-policies">
    Limit a credential to the operations an agent needs.
  </Card>
</CardGroup>
