Give your scheduling agent its own identity

Give your scheduling agent its own identity

4 min read

    If you are building an AI agent that books meetings for sales, product workflows, or personal assistants, you’ve probably hit the same wall. A scheduling agent can draft an email and propose three times. It cannot create the event. The moment the user picks a slot, a human steps in, or the agent calls a second system that owns a calendar.

    Nylas Agent Accounts close that gap. The same identity that proposes the times creates the event, sends the invite, and handles RSVPs. Here is how to build the loop.

    Before you start, you’ll need an Agent Account on a registered domain and a webhook endpoint Nylas can reach. The quickstart sets both up in about 5 minutes.

    Provision the account

    Create a dedicated scheduling inbox by provisioning a Nylas Agent Account. Your backend makes one API call and gets back a grant ID. That grant ID works with every Nylas endpoint, MCP server, and CLI immediately.

    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",
        "settings": {
          "email": "[email protected]"
        }
      }'
    

    Response:

    {
      "data": {
        "id": "b1c2d3e4-5678-4abc-9def-0123456789ab",
        "provider": "nylas",
        "grant_status": "valid",
        "email": "[email protected]"
      }
    }
    

    The grant ID is at data.id. That is what you pass on every subsequent API call. The account’s primary calendar is provisioned automatically alongside the mailbox, so you can create events on it immediately.

    To test the flow locally before wiring it into your backend, the CLI runs the same provisioning:

    nylas agent account create [email protected]

    Subscribe to webhooks

    The agent’s flow runs on two event types: inbound messages to receive meeting requests and confirmation replies, and calendar activity to track RSVPs and reschedules.

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

    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. Every subsequent POST carries an X-Nylas-Signature header you should verify before processing.

    One note: Nylas blocks requests to ngrok URLs. Use VS Code port forwarding or Hookdeck for local development.

    The request-to-booking loop

    When someone emails [email protected], Nylas fires message.created with the message object in data.object. The webhook payload carries summary fields; your handler fetches the full body to pass to the LLM. One note on field casing: webhook payloads arrive as raw JSON in snake_case (grant_id, thread_id), while values the SDK returns are camelCase (threadId, messageIds). Seeing both in one handler is expected, not a typo.

    const crypto = require("crypto");
    const SCHEDULING_EMAIL = "[email protected]";
    
    // Raw body required so HMAC matches the exact bytes Nylas signs
    app.use("/webhooks/nylas", express.raw({ type: "application/json" }));
    // Nylas sends a GET with ?challenge=... when the webhook is first activated.
    // Echo the value back within 10 seconds to verify the endpoint.
    app.get("/webhooks/nylas", (req, res) => res.status(200).send(req.query.challenge));
    
    app.post("/webhooks/nylas", async (req, res) => {
      // Verify the request is from Nylas
      const signature = req.headers["x-nylas-signature"];
      const digest = crypto
        .createHmac("sha256", process.env.NYLAS_WEBHOOK_SECRET)
        .update(req.body) // raw Buffer, not parsed JSON
        .digest("hex");
    
      if (!signature || signature.length !== digest.length || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) return res.status(401).end();
    
      res.status(200).end(); // Acknowledge immediately, do work async
    
      const event = JSON.parse(req.body.toString());
    
      if (event.type !== "message.created") return;
    
      const message = event.data.object;
    
      // Ignore messages from other grants
      if (message.grant_id !== AGENT_GRANT_ID) return;
    
      // Ignore the agent's own outbound messages
      if (message.from?.[0]?.email === SCHEDULING_EMAIL) return;
    
      const full = await nylas.messages.find({
        identifier: AGENT_GRANT_ID,
        messageId: message.id,
      });
    
      const parsed = await llm.parseMeetingRequest(full.data);
      if (parsed.intent !== "schedule_meeting") return;
    
      await proposeTimes(message, parsed);
    });

    To propose times, check the agent’s availability against its own calendar using the free/busy endpoint, then hand the open slots to the LLM to draft a reply:

    async function proposeTimes(message, parsed) {
      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: ["[email protected]"],
        },
      });
    
      // Generate potential slots within the prospect's preferred window
      const candidates = generateCandidateSlots(
        parsed.preferredWindow,
        parsed.duration ?? 30
      );
    
      const openSlots = candidates.filter(
        (slot) => !overlapsAnyBusyBlock(slot, freeBusy.data)
      );
    
      const reply = await llm.draftProposal({
        originalMessage: message,
        openSlots: openSlots.slice(0, 3),
      });
    
      await nylas.messages.send({
        identifier: AGENT_GRANT_ID,
        requestBody: {
          replyToMessageId: message.id,
          to: message.from,
          subject: `Re: ${message.subject}`,
          body: reply,
        },
      });
    }

    Nylas preserves the Message-ID, In-Reply-To, and References headers on outbound, so the reply lands in the original thread in Gmail, Outlook, or any other client. When the prospect replies with their chosen slot, that reply groups under the same thread ID and your handler picks it up with full context.

    When the prospect confirms, create the event with notifyParticipants: true. Nylas sends an ICS REQUEST from the agent’s address and the recipient’s calendar client treats it as a standard invitation:

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

    Handling RSVPs and reschedules

    When the invitee accepts, declines, or proposes a new time, Nylas fires event.updated with the attendee status change. Your handler branches on the RSVP:

    if (event.type === "event.updated") {
      const changedEvent = event.data.object;
      const rsvp = changedEvent.participants.find(
        (p) => p.email !== "[email protected]"
      );
    
      if (rsvp?.status === "no") {
        await offerAlternatives(changedEvent);
      } else if (rsvp?.status === "yes") {
        await confirmBooking(changedEvent);
      }
    }

    If the invitee proposes a different time, RSVP on the agent’s behalf with POST /v3/grants/{grant_id}/events/{id}/send-rsvp. The response goes out as a standard ICS REPLY visible to every participant.

    Know when the invite lands

    Creating the event is only half the job. An agent that can’t tell a delivered invite from a silent bounce is flying blind, and a meeting that never reached the prospect is worse than no meeting at all. The same webhook stream that carries inbound mail also carries deliverability signals for everything the agent sends.

    Add message.send_success, message.send_failed, and message.bounce_detected to your triggers. send_success confirms the invite or reply actually left the building; send_failed and bounce_detected tell you the address is wrong or the mail was rejected, so the agent can fix the address or flag the booking for a human instead of waiting on a confirmation that will never come.

    What to build next

    Each recipe extends the loop for a specific need:

    Provision your first Agent Account

    nylas agent account create [email protected]

    Related resources

    Introducing Nylas Agent Accounts

    Email and calendar for your AI agents. A complete communication identity, provisioned with a single…

    Build a support agent that owns its inbox

    If you are building a support agent, closing a ticket in one turn is the…