# Build a scheduling agent with a dedicated identity

Source: https://developer.nylas.com/docs/cookbook/use-cases/act/scheduling-agent-with-dedicated-identity/

A scheduling agent that replies from a human's inbox is a compromise. Responses come from the human's address, their calendar fills with bookings the agent owns, and the agent has no identity of its own. This recipe gives the agent its own mailbox and calendar: meeting requests land at `scheduling@yourcompany.com`, an LLM parses them, the agent proposes times against its own free/busy, and creates events on its own calendar. No human mailbox in the loop.

The agent runs on a [Nylas Agent Account](/docs/v3/agent-accounts/) -- a Nylas-hosted mailbox and calendar you provision through the API. Every grant-scoped endpoint you'd normally hit against a connected user (list messages, send mail, create events, RSVP) works the same way against a grant you own.

## The loop

```
human emails scheduling@agents.yourcompany.com
                │
message.created webhook ─▶ LLM parses intent (duration, timezone, urgency)
                │
                ▼
       agent queries own free/busy ─▶ replies with 3 candidate slots
                │
human picks one ─▶ message.created webhook ─▶ agent creates event with notify_participants=true
                                                             │
                                                             ▼
                                              attendee accepts/declines/proposes new time
                                                             │
                                              event.updated webhook ─▶ agent confirms or offers alternatives
```

By the end, you'll have a scheduling identity that coordinates meetings end-to-end without touching any human's account.

## Before you begin


Make sure you have the following before starting this tutorial:

- A [Nylas account](https://dashboard-v3.nylas.com/) with an active application
- A valid **API key** from your Nylas Dashboard
- At least one **connected grant** (an authenticated user account) for the provider you want to work with
- **Node.js 18+** or **Python 3.8+** installed (depending on which code samples you follow)

> **Info:** 
> **New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.


You also need:

- A **domain registered with Nylas** — either a Nylas-provided `*.nylas.email` trial subdomain for prototyping, or your own domain with MX + TXT records in place. See [Setup domains](/docs/v3/agent-accounts/dns-provider-setup/).
- A **publicly accessible HTTPS webhook endpoint**. During development, use [VS Code port forwarding](https://code.visualstudio.com/docs/editor/port-forwarding) or [Hookdeck](https://hookdeck.com/) to expose your local server.
- Access to an **LLM** for parsing meeting requests and drafting replies. Any OpenAI-compatible API works — this guide keeps the LLM calls abstract so you can drop in Claude, GPT, Gemini, or a local model.

> **Warn:** 
> **Nylas blocks requests to ngrok URLs** because of throughput limiting concerns. Use VS Code port forwarding or Hookdeck instead.

## Step 1: Provision the Agent Account

The quickest path is the [Nylas CLI](/docs/v3/getting-started/cli/):

```bash
nylas agent account create scheduling@agents.yourcompany.com
```

Save the grant ID the CLI prints — that's what you'll use on every subsequent call. Or, if you'd rather use the API, the same thing through [`POST /v3/connect/custom`](/docs/reference/api/manage-grants/byo_auth/) with `"provider": "nylas"` (no OAuth refresh token required):

```bash
curl --request POST \
  --url "https://api.us.nylas.com/v3/connect/custom" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "provider": "nylas",
    "name": "Scheduling Assistant",
    "settings": {
      "email": "scheduling@agents.yourcompany.com"
    }
  }'
```

The top-level `name` is what gives this identity a human face: Nylas uses it as the default `From` display name, so meeting requests and confirmations arrive from `Scheduling Assistant <scheduling@agents.yourcompany.com>` rather than a bare address. See [Set a display name](/docs/v3/agent-accounts/provisioning/#set-a-display-name) if you want to vary it per message.

Either way, the agent's primary calendar is provisioned automatically — no extra call is needed before you can create events on it.

### Optionally attach a policy

If you want to enforce send quotas, spam detection, or inbound rules on the agent, create a [policy](/docs/v3/agent-accounts/policies-rules-lists/) and set it as the `policy_id` on the agent's workspace. Without one, the account runs at your billing plan's maximum limits. A scheduling agent typically has relaxed send limits but strict DNSBL + header-anomaly spam detection to keep auto-generated replies from flowing to garbage senders.

## Step 2: Subscribe to webhooks

The agent needs to hear about two kinds of events: new inbound messages (so it can respond to requests) and calendar activity on its own events (so it can follow up on RSVPs or reschedules).

From the [Nylas CLI](/docs/v3/getting-started/cli/):

```bash
nylas webhook create \
  --url https://youragent.example.com/webhooks/nylas \
  --triggers "message.created,event.created,event.updated,event.deleted"
```

Or through the API:

```bash
curl --request POST \
  --url "https://api.us.nylas.com/v3/webhooks" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "trigger_types": [
      "message.created",
      "event.created",
      "event.updated",
      "event.deleted"
    ],
    "description": "Scheduling agent",
    "webhook_url": "https://youragent.example.com/webhooks/nylas",
    "notification_email_addresses": ["ops@yourcompany.com"]
  }'
```

Nylas sends a challenge `GET` to your endpoint when the webhook is created; respond with the value of the `challenge` query parameter within 10 seconds to activate it. See [Using webhooks with Nylas](/docs/v3/notifications/) for the signature-verification code your handler should run on every incoming POST.

## Step 3: Handle an inbound meeting request

When a human emails `scheduling@agents.yourcompany.com` to ask for a meeting, Nylas fires `message.created` with the Message object in `data.object`. The webhook only carries summary fields — your handler fetches the full body from the API.

```js
// Node.js / Express handler sketch
app.post("/webhooks/nylas", async (req, res) => {
  // (Verify X-Nylas-Signature here — see the webhooks guide.)
  res.status(200).end(); // Acknowledge immediately; do work async.

  const event = req.body;
  if (event.type !== "message.created") return;

  const message = event.data.object;

  // Fetch the full message to get the body and headers.
  const full = await nylas.messages.find({
    identifier: AGENT_GRANT_ID,
    messageId: message.id,
  });

  // Hand to the LLM for intent extraction.
  const parsed = await llm.parseMeetingRequest(full.data);

  if (parsed.intent !== "schedule_meeting") return;

  // Propose times (next step).
  await proposeTimes(message, parsed);
});
```

## Step 4: Propose candidate times

Ask the LLM for the requested duration and timezone, then check the agent's availability for matching slots. The agent calls [`/calendars/free-busy`](/docs/reference/api/calendar/post-calendars-free-busy/) against its own primary calendar.

```js
async function proposeTimes(message, parsed) {
  const candidates = generateCandidateSlots(parsed.preferredWindow, parsed.durationMinutes);

  const freeBusy = await nylas.calendars.getFreeBusy({
    identifier: AGENT_GRANT_ID,
    requestBody: {
      startTime: Math.floor(parsed.preferredWindow.start.getTime() / 1000),
      endTime: Math.floor(parsed.preferredWindow.end.getTime() / 1000),
      emails: ["scheduling@agents.yourcompany.com"],
    },
  });

  const openSlots = candidates.filter(
    (slot) => !overlapsAnyBusyBlock(slot, freeBusy.data),
  );

  const reply = await llm.draftProposal({
    originalMessage: message,
    openSlots: openSlots.slice(0, 3),
    tone: "friendly, concise",
  });

  await nylas.messages.send({
    identifier: AGENT_GRANT_ID,
    requestBody: {
      replyToMessageId: message.id,
      to: message.from,
      subject: `Re: ${message.subject}`,
      body: reply,
    },
  });
}
```

The recipient sees a normal reply from `scheduling@agents.yourcompany.com` — no relay footer, no sent-via branding. If they've emailed the agent before, the reply threads correctly because Nylas preserves the `Message-ID`, `In-Reply-To`, and `References` headers on outbound.

## Step 5: Create the event when the human confirms

When the human replies with their chosen slot, the agent receives another `message.created` webhook, parses it with the LLM to extract the selected time, and creates the event. Pass `notify_participants=true` so Nylas sends an ICS `REQUEST` from the agent's address — the recipient's calendar client (Google, Microsoft, Apple) displays it as a normal invitation.

```js
async function createEvent(message, parsed) {
  const event = await nylas.events.create({
    identifier: AGENT_GRANT_ID,
    queryParams: {
      calendarId: "primary",
      notifyParticipants: true,
    },
    requestBody: {
      title: parsed.title,
      description: parsed.description,
      when: {
        startTime: parsed.startUnix,
        endTime: parsed.endUnix,
      },
      participants: [{ email: message.from[0].email, name: message.from[0].name }],
    },
  });

  return event.data.id;
}
```

Nylas fires an `event.created` webhook on your endpoint as the event lands on the agent's calendar.

## Step 6: Track RSVPs and handle changes

When the invitee accepts, declines, or proposes a new time, Nylas fires `event.updated` (with the attendee's status change) on your webhook. Pair that with LLM-drafted follow-up logic to keep the loop closed:

```js
if (event.type === "event.updated") {
  const changedEvent = event.data.object;
  const rsvp = changedEvent.participants.find(
    (p) => p.email !== "scheduling@agents.yourcompany.com",
  );

  if (rsvp?.status === "declined") {
    // LLM-driven: offer alternative times automatically.
    await offerAlternatives(changedEvent);
  } else if (rsvp?.status === "yes") {
    // Confirm and queue reminders.
    await confirmBooking(changedEvent);
  }
}
```

If the invitee proposes a reschedule instead of accepting, you can RSVP on the agent's behalf with [`/events/{id}/send-rsvp`](/docs/reference/api/events/send-rsvp/) — `yes`, `no`, or `maybe`. The response goes out as a standard ICS `REPLY` visible to every participant.

## Step 7: (Optional) Give humans access to the mailbox

If your ops team needs to see what the agent is doing day-to-day — read the inbox, audit replies, intervene in edge cases — enable protocol access by setting `app_password` on the grant and having humans connect Outlook, Apple Mail, or Thunderbird. Every IMAP action syncs back to the API, so humans and the agent operate on the same mailbox. See [Mail client access (IMAP & SMTP)](/docs/v3/agent-accounts/mail-clients/).

## Things to know

- **Rate limits are per grant.** A busy scheduling agent can hit the default 100-message-per-day send cap on an Agent Account. Provision a policy with a higher cap before you launch.
- **The agent doesn't know what it doesn't see.** A meeting request that lands in junk won't fire a `message.created` webhook on the inbox. If you're using rules to auto-route, confirm important senders aren't caught by spam detection. The [rule evaluations endpoint](/docs/reference/api/rules/list-rule-evaluations/) is the fastest way to audit.
- **LLM reliability matters more than latency here.** Parse-time is forgiving (minutes, not milliseconds), but wrong intent extraction creates real calendar chaos. Require a human-in-the-loop confirmation for first-time senders or high-value meetings.
- **Use separate agents for separate roles.** Sales outreach, customer support, and scheduling have very different send quotas, spam sensitivities, and reply patterns. Model each as its own Agent Account with its own policy rather than one catch-all.
- **Don't ship without thread continuity testing.** The agent's reply must preserve `Message-ID`, `In-Reply-To`, and `References` so the conversation threads in Gmail/Outlook. Send yourself a request and verify the reply lands in the original thread.

## Next steps

- [Support agent with multi-day threads](/docs/cookbook/use-cases/act/support-agent-multi-day-threads/) -- the same Agent Account pattern for support inboxes
- [Handle email replies in an agent loop](/docs/cookbook/agent-accounts/handle-replies/) -- the underlying reply-detection recipe
- [Multi-turn email conversations](/docs/cookbook/agent-accounts/multi-turn-conversations/) -- conversation state machine for agents
- [Agent Accounts overview](/docs/v3/agent-accounts/) -- product doc, limits, and architecture
- [Supported endpoints for Agent Accounts](/docs/v3/agent-accounts/supported-endpoints/) -- every grant-scoped endpoint and webhook the agent can use
- [Policies, Rules, and Lists](/docs/v3/agent-accounts/policies-rules-lists/) -- spam tuning, send quotas, and inbound filtering
- [Mail client access (IMAP & SMTP)](/docs/v3/agent-accounts/mail-clients/) -- let humans log into the agent's mailbox alongside the API