# Build recipient autocomplete

Source: https://developer.nylas.com/docs/cookbook/contacts/recipient-autocomplete/

Type two letters into Gmail's "To:" field and it suggests the right person before you finish their name. Users now expect that everywhere, and a blank compose box with no suggestions feels broken. Building it per provider is the painful part: Google's People API and Microsoft Graph each have their own auth, paging, and field names, and neither one covers the other's contacts.

The [Nylas Contacts API](/docs/v3/email/contacts/) returns both address books through one endpoint with one schema. You pull contacts once, cache them, and filter on the client as the user types. That's the whole pattern, and it works the same whether the signed-in account is Gmail, Outlook, or Exchange.

## How do I build recipient autocomplete from contacts?

Recipient autocomplete is a 3-step pattern: fetch the user's contacts once, cache them in memory, then filter that cache by prefix on every keystroke. You hit the API a handful of times at load, not once per character, so suggestions render in under a millisecond instead of waiting on a network round trip for each letter typed.

The steps below map directly to the sections in this recipe:

1. **Fetch** all contacts from the [Contacts API](/docs/v3/email/contacts/) and page through the results.
2. **Cache** them in a flat list of `{ name, email }` entries keyed for fast prefix matching.
3. **Filter** that cache as the user types, debouncing input so you rank and render only the top few matches.

The same flow covers Google, Microsoft, and Exchange because the API returns one unified contact object. You write the picker once instead of branching on provider.

## Fetch contacts to build the index

Send a `GET` request to `/v3/grants/{grant_id}/contacts` to read the user's address book. The response is a `data` array of contact objects, each carrying an `emails` array plus name fields like `given_name` and `surname`. The default page size is 30 contacts and the maximum is 200, so you typically page through everyone once at load and cache the result.

The request below lists contacts for a grant. Raise `limit` to 200 to cut the number of round trips, then follow the `next_cursor` to fetch the next page.

```bash
curl --compressed --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/contacts' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json'

```

```json
{
  "request_id": "1",
  "data": [
    {
      "birthday": "1960-12-31",
      "company_name": "Nylas",
      "emails": [
        {
          "type": "work",
          "email": "leyah.miller@example.com"
        },
        {
          "type": "home",
          "email": "leyah@example.com"
        }
      ],
      "given_name": "Leyah",
      "grant_id": "<NYLAS_GRANT_ID>",
      "groups": [{ "id": "starred" }, { "id": "friends" }],
      "id": "<CONTACT_ID>",
      "im_addresses": [
        {
          "type": "jabber",
          "im_address": "jabber_at_leyah"
        },
        {
          "type": "msn",
          "im_address": "leyah.miller"
        }
      ],
      "job_title": "Software Engineer",
      "manager_name": "Nyla",
      "middle_name": "Allison",
      "nickname": "Allie",
      "notes": "Loves ramen",
      "object": "contact",
      "office_location": "123 Main Street",
      "phone_numbers": [
        {
          "type": "work",
          "number": "+1-555-555-5555"
        },
        {
          "type": "home",
          "number": "+1-555-555-5556"
        }
      ],
      "physical_addresses": [
        {
          "type": "work",
          "street_address": "123 Main Street",
          "postal_code": "94107",
          "state": "CA",
          "country": "US",
          "city": "San Francisco"
        },
        {
          "type": "home",
          "street_address": "123 Main Street",
          "postal_code": "94107",
          "state": "CA",
          "country": "US",
          "city": "San Francisco"
        }
      ],
      "picture_url": "https://example.com/picture.jpg",
      "source": "address_book",
      "surname": "Miller",
      "web_pages": [
        {
          "type": "work",
          "url": "<WEBPAGE_URL>"
        },
        {
          "type": "home",
          "url": "<WEBPAGE_URL>"
        }
      ]
    }
  ],
  "next_cursor": "2"
}


```

```js [listContacts-Node.js]

import Nylas from "nylas";

const nylas = new Nylas({
  apiKey: "<NYLAS_API_KEY>",
  apiUri: "<NYLAS_API_URI>",
});

async function fetchContacts() {
  try {
    const identifier = "<NYLAS_GRANT_ID>";
    const contacts = await nylas.contacts.list({
      identifier,
      queryParams: {},
    });

    console.log("Recent Contacts:", contacts);
  } catch (error) {
    console.error("Error fetching drafts:", error);
  }
}

fetchContacts();


```

```python
from nylas import Client

nylas = Client(
    "<NYLAS_API_KEY>",
    "<NYLAS_API_URI>"
)

grant_id = "<NYLAS_GRANT_ID>"

contacts = nylas.contacts.list(
  grant_id,
)

print(contacts)

```

Pass the `next_cursor` value as the `page_token` query parameter to read the next page. Keep looping until a response comes back without a `next_cursor`, which means you've reached the end of the address book.

## How do I cache contacts for fast autocomplete?

Cache contacts client-side because per-keystroke API calls are too slow and too expensive. A network round trip adds 50 to 300 milliseconds per request, and a fast typist fires 5 or more keystrokes per second, so live calls both feel laggy and burn through rate limits. Filtering an in-memory list instead returns matches in well under a millisecond.

Flatten the API response into one entry per email address, then filter that array by prefix. Debounce the input by about 150 milliseconds so you match on a pause, not on every letter. The snippet below builds the index once and exposes a `search` function the input handler calls.

```js [autocompleteIndex-Node.js]
// Flatten contacts into one { name, email } entry per address.
const buildIndex = (contacts) =>
  contacts.flatMap((c) => {
    const name = [c.given_name, c.surname].filter(Boolean).join(" ");
    return (c.emails ?? []).map((e) => ({
      name,
      email: e.email.toLowerCase(),
    }));
  });

// Prefix-match on name or email. Cap results so the dropdown stays short.
const search = (index, query, max = 5) => {
  const q = query.trim().toLowerCase();
  if (!q) return [];
  return index
    .filter((e) => e.name.toLowerCase().startsWith(q) || e.email.startsWith(q))
    .slice(0, max);
};

// Refresh the cache every 5 minutes so new contacts show up.
const index = buildIndex(await fetchAllContacts(grantId));
setInterval(async () => {
  index.length = 0;
  index.push(...buildIndex(await fetchAllContacts(grantId)));
}, 5 * 60 * 1000);
```

Refresh the cache on a timer, every 5 minutes is a reasonable default, so contacts the user adds mid-session still surface. For accounts that send webhooks, the [`contact.updated` trigger](/docs/cookbook/contacts/list-and-sync-contacts/) lets you patch the cache on change instead of rebuilding it on a fixed interval.

## How do I include people the user writes to but hasn't saved?

Set the `source` query parameter to `inbox` to surface everyone the user has corresponded with, beyond saved contacts. The API recognizes 3 sources: `address_book` (contacts the user saved), `domain` (the organization directory), and `inbox` (auto-generated contacts pulled from sent and received mail). The `inbox` source is what fills a picker with real correspondents who were never added by hand.

The request below merges the `address_book` and `inbox` sources so your index covers both saved people and frequent correspondents. Compound source filters like this work on IMAP and iCloud. For other providers, query each source on its own and merge the results yourself.

```bash
curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/contacts?source=inbox' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

Coverage of these auto-generated contacts varies by provider. The `inbox` source needs the `contacts.other.readonly` scope on Google and `People.Read` on Microsoft, and Exchange (EWS) doesn't support it at all. For EWS, build the picker on saved contacts plus recipients you pull from the [Messages API](/docs/v3/email/) instead. The [scopes reference](/docs/dev-guide/scopes/#contacts-api-scopes) lists the exact strings.

## Things to know about autocomplete data

A good recipient picker is more than a prefix filter. A few details separate a usable index from a frustrating one, and most of them come down to how you shape the cached data before it ever reaches the dropdown.

**Deduplicate by email, not by name.** One person often appears more than once, as a saved `address_book` entry and again as an auto-generated `inbox` contact. Lowercase every address and key your index by email address so the same person shows up once. Two contacts can share a display name, so never deduplicate on name alone.

**Rank by how often the user writes to someone.** Alphabetical order buries the people the user writes to most. Pull recent sent messages from the [Messages API](/docs/v3/email/), count how often each address appears as a recipient, and sort matches by that frequency. The 5 to 10 most-contacted addresses cover the majority of real sends, so weighting by send count makes the first suggestion correct far more often.

**Don't expose the whole address book.** Return suggestions only after the user types at least 2 characters, and cap the dropdown at 5 to 8 entries. Dumping every contact into the page leaks the user's full network into client-side state and slows rendering. Filter server-side if your address books run to thousands of entries.

**Watch the provider quirks.** Google organizes contacts into [groups](https://developers.google.com/people/api/rest/v1/contactGroups) and polls for changes every 5 minutes, so a contact added seconds ago may not appear yet. Microsoft Graph caps a single contact at 3 email addresses and leaves the email `type` as `null`, so don't rely on `work` or `home` labels to pick a primary address. Build your ranking on send frequency instead of provider labels.

## What's next

- [List and sync contacts from Google and Outlook](/docs/cookbook/contacts/list-and-sync-contacts/) to keep the cache current with webhooks
- [List Google contacts](/docs/cookbook/contacts/list-contacts-google/) for Google-specific source and scope details
- [Using the Contacts API](/docs/v3/email/contacts/) for the full contact schema, filters, and per-provider limits
- [Email API](/docs/v3/email/) to pull sent-message recipients and rank suggestions by frequency