> For the complete documentation index, see [llms.txt](https://docs.shortcutco.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.shortcutco.com/developers/guides/daily-schedule-bot.md).

# Daily schedule bot

Post each crew's route for the day to Slack every morning with a short script and a read-only token.

After this guide, a message like this lands in your team's Slack channel every morning before the crews roll out:

```
Schedule for Friday, October 16 (14 visits)

North crew (8)
 1. Robin Alvarez, 412 Elm St, Bozeman, MT 59715 (Weekly mowing)
 2. Okafor Dental, 1180 N 19th Ave, Bozeman, MT 59718 (Weekly mowing)
 ...
South crew (5)
 ...
Unassigned (1)
 - Jamie Chen, 88 Willow Way, Bozeman, MT 59718 (Aeration)
```

It takes about 15 minutes: a read-only token, a Slack incoming webhook, and one script you run on a schedule. No AI is involved; the script calls the Shortcut MCP server's `get_schedule` tool directly over HTTPS, because that tool returns the day already grouped by crew in route order, with crew and customer names, in one call.

## 1. Create a read-only token

In **Settings → API & integrations**, select **Create token**:

* **Name:** `Schedule bot`.
* **Scopes:** **Custom** with **Read visits**, **Read crews**, and **Read customers**. Read crews adds crew names and Read customers adds customer names (the company name for businesses); the schedule itself needs only Read visits.
* **Expiry:** 1 year.

## 2. Create a Slack incoming webhook

In Slack, create an app with **Incoming Webhooks** turned on for the channel the crews read, and copy the webhook URL. Slack's guide: [Sending messages using incoming webhooks](https://api.slack.com/messaging/webhooks). Any chat tool that accepts a JSON `POST` works the same way.

## 3. Try the call

```bash
curl https://api.shortcutco.com/mcp \
  -X POST \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2025-11-25" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_schedule", "arguments": {}}}'
```

With no arguments, `get_schedule` returns today in your workspace's timezone. The answer's `result.structuredContent` holds the schedule (the same JSON is in `result.content[0].text` as a string, left out here):

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [{ "type": "text", "text": "…" }],
    "structuredContent": {
      "data": {
        "today": "2026-10-16",
        "from": "2026-10-16",
        "to": "2026-10-16",
        "totalVisits": 2,
        "days": [
          {
            "date": "2026-10-16",
            "totalVisits": 2,
            "teams": [
              {
                "teamId": "6c7d8e9f-0a1b-4c2d-8e3f-4a5b6c7d8e9f",
                "totalVisits": 1,
                "visits": [
                  {
                    "id": "5e0c8a1f-2b7d-4c3e-9f6a-8d1b2c3e4f50",
                    "object": "visit",
                    "date": "2026-10-16",
                    "status": "scheduled",
                    "closeReason": null,
                    "jobId": "a4f1c9e2-7b3d-4e8a-9c5f-1d2e3f4a5b6c",
                    "jobName": "Weekly mowing",
                    "customerId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
                    "propertyId": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
                    "address": "412 Elm St, Bozeman, MT 59715",
                    "assignedTeamId": null,
                    "effectiveTeamId": "6c7d8e9f-0a1b-4c2d-8e3f-4a5b6c7d8e9f",
                    "routePosition": 1,
                    "plannedStartAt": "2026-10-16T14:00:00.000Z",
                    "plannedEndAt": "2026-10-16T14:30:00.000Z",
                    "estimatedDurationMinutes": 30,
                    "arrivedAt": null,
                    "completedAt": null,
                    "invoiceId": null,
                    "createdAt": "2026-04-02T15:12:44.120Z",
                    "updatedAt": "2026-10-14T21:03:10.552Z"
                  }
                ]
              },
              {
                "teamId": null,
                "totalVisits": 1,
                "visits": [
                  {
                    "id": "8b9c0d1e-2f3a-4b4c-9d5e-6f7a8b9c0d1e",
                    "object": "visit",
                    "date": "2026-10-16",
                    "status": "scheduled",
                    "closeReason": null,
                    "jobId": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
                    "jobName": "Aeration",
                    "customerId": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
                    "propertyId": "7f8e9d0c-1b2a-4c3d-8e4f-5a6b7c8d9e0f",
                    "address": "88 Willow Way, Bozeman, MT 59718",
                    "assignedTeamId": null,
                    "effectiveTeamId": null,
                    "routePosition": null,
                    "plannedStartAt": null,
                    "plannedEndAt": null,
                    "estimatedDurationMinutes": 45,
                    "arrivedAt": null,
                    "completedAt": null,
                    "invoiceId": null,
                    "createdAt": "2026-10-16T19:40:02.511Z",
                    "updatedAt": "2026-10-16T19:40:02.511Z"
                  }
                ]
              }
            ]
          }
        ],
        "teamNames": { "6c7d8e9f-0a1b-4c2d-8e3f-4a5b6c7d8e9f": "North crew" },
        "customerNames": {
          "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d": "Robin Alvarez",
          "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d": "Jamie Chen"
        }
      },
      "meta": { "hasMore": false, "nextCursor": null }
    }
  }
}
```

Visits come in schedule order: by date, then crew (unassigned last, with `teamId: null`), then route position. A page holds at most 50 visits; on a bigger day `meta.hasMore` is `true` and you pass `meta.nextCursor` back as the `cursor` argument. `totalVisits` on each crew tells you when a crew's list continues on the next page.

## 4. The script

```js
// schedule-bot.mjs: Node 18 or later, no dependencies.
// SHORTCUT_TOKEN=scut_live_... SLACK_WEBHOOK_URL=https://hooks.slack.com/... node schedule-bot.mjs

async function getSchedulePage(cursor) {
  const response = await fetch("https://api.shortcutco.com/mcp", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SHORTCUT_TOKEN}`,
      "Content-Type": "application/json",
      Accept: "application/json, text/event-stream",
      "MCP-Protocol-Version": "2025-11-25",
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name: "get_schedule", arguments: cursor ? { cursor } : {} },
    }),
  });
  const message = await response.json();
  const requestId = response.headers.get("x-request-id");
  if (message.error) throw new Error(`${message.error.message} (request ${requestId})`);
  if (message.result.isError) {
    const { code, message: text } = message.result.structuredContent.error;
    throw new Error(`${code}: ${text} (request ${requestId})`);
  }
  return message.result.structuredContent;
}

// Read every page, merging crews that continue across pages.
const crews = new Map();
const names = { teams: {}, customers: {} };
let today;
let cursor = null;
do {
  const { data, meta } = await getSchedulePage(cursor);
  today = data.today;
  Object.assign(names.teams, data.teamNames);
  Object.assign(names.customers, data.customerNames);
  for (const day of data.days) {
    for (const team of day.teams) {
      const key = team.teamId ?? "unassigned";
      if (!crews.has(key)) crews.set(key, { teamId: team.teamId, visits: [] });
      crews.get(key).visits.push(...team.visits);
    }
  }
  cursor = meta.nextCursor;
} while (cursor);

const heading = new Date(`${today}T12:00:00Z`).toLocaleDateString("en-US", {
  weekday: "long", month: "long", day: "numeric", timeZone: "UTC",
});
const total = [...crews.values()].reduce((sum, crew) => sum + crew.visits.length, 0);
const lines = [`*Schedule for ${heading}* (${total} visits)`];
for (const crew of crews.values()) {
  const crewName = crew.teamId ? names.teams[crew.teamId] ?? "Crew" : "Unassigned";
  lines.push("", `*${crewName}* (${crew.visits.length})`);
  for (const visit of crew.visits) {
    const stop = visit.routePosition ? `${visit.routePosition}.` : "-";
    const customer = names.customers[visit.customerId] ?? "Customer";
    lines.push(` ${stop} ${customer}, ${visit.address ?? "no address"} (${visit.jobName})`);
  }
}
if (total === 0) lines.push("", "Nothing on the schedule today.");

const posted = await fetch(process.env.SLACK_WEBHOOK_URL, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text: lines.join("\n") }),
});
if (!posted.ok) throw new Error(`Slack answered ${posted.status}`);
```

Run it once by hand and check the channel:

```bash
SHORTCUT_TOKEN="scut_live_paste_your_token_here" SLACK_WEBHOOK_URL="https://hooks.slack.com/services/paste/your/url" node schedule-bot.mjs
```

The heading uses `today` from the answer, the date in your workspace's timezone, so the bot is right wherever it runs.

## 5. Run it every morning

Any scheduler works. With cron on a server whose clock is on your business's time, every day at 6:00 AM:

```
0 6 * * * cd /opt/schedule-bot && SHORTCUT_TOKEN=scut_live_... SLACK_WEBHOOK_URL=https://hooks.slack.com/... node schedule-bot.mjs
```

On GitHub Actions, a scheduled workflow runs it with the token and webhook URL stored as repository secrets (`cron` there is in UTC: 6:00 AM in Denver is `0 12 * * *` in summer and `0 13 * * *` in winter).

## Variations

* **One crew per channel.** Pass `{ teamId: "…" }` as the `get_schedule` arguments to get one crew's visits, and post each crew to its own channel.
* **Tomorrow, the evening before.** Pass `{ from: "2026-10-17" }`; compute tomorrow from `today` in the first answer.
* **The whole week.** Pass `from` and `to` up to 31 days apart; the answer groups by date first.
* **Straight from the REST API.** `GET /v1/visits?from=2026-10-16&to=2026-10-16` returns the day's visits, including `pending` ones that are not on the schedule yet, newest first, each with `effectiveTeamId` and `routePosition`. Filter by `status`, sort by crew and route position, and take crew names from `GET /v1/teams` to build the same list.
