---
title: "Notion Workers: Stop Agents Guessing Customer Lookups"
description: "Build a read-only lookupCustomer tool in a Notion Worker, then attach it to a Custom Agent so it answers with a plan and status instead of a guess."
canonical: "https://adamtheautomator.com/notion-worker-customer-lookup/"
---

# Notion Workers: Stop Agents Guessing Customer Lookups

> Build a read-only lookupCustomer tool in a Notion Worker, then attach it to a Custom Agent so it answers with a plan and status instead of a guess.

Source: https://adamtheautomator.com/notion-worker-customer-lookup/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Notion Workers: Stop Agents Guessing Customer Lookups](https://adamtheautomator.com/wp-content/uploads/publisher/3f25d9c85b2b817b9916e1d7c0ce0d01/9553685a308ab52c50530c8cc96e715bb8f2194da3d00a46fb09a3e8836a559a.webp)

# Notion Workers: Stop Agents Guessing Customer Lookups

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)26 October 202614 min. read

Categories: [AI Agents](/category/ai-agents/)

Tags:[Notion](/tag/notion/)[AI](/tag/ai/)[API](/tag/api/)[TypeScript](/tag/typescript/)

Table of Contents

*   [What a Notion Worker Tool Adds Beyond a Prebuilt MCP Connection](#what-a-notion-worker-tool-adds-beyond-a-prebuilt-mcp-connection)
*   [Why This One Belongs in a Tool](#why-this-one-belongs-in-a-tool)
*   [Prerequisites for a Notion Worker Tool on a Business Workspace](#prerequisites-for-a-notion-worker-tool-on-a-business-workspace)
*   [Step 1: Scaffold the Worker and Lock the Tool Key](#step-1-scaffold-the-worker-and-lock-the-tool-key)
*   [Step 2: Define the Input and Output Schema](#step-2-define-the-input-and-output-schema)
*   [Why Every Field Is Required](#why-every-field-is-required)
*   [What the Output Schema Buys You](#what-the-output-schema-buys-you)
*   [Step 3: Make the Outbound Call Without the Credential](#step-3-make-the-outbound-call-without-the-credential)
*   [Store the Credential With the Worker](#store-the-credential-with-the-worker)
*   [What the Call Does When the CRM Breaks](#what-the-call-does-when-the-crm-breaks)
*   [Step 4: Stand Up a Synthetic Customer API to Test Against](#step-4-stand-up-a-synthetic-customer-api-to-test-against)
*   [Step 5: Test Unknown IDs and a Dead API Before Deploying](#step-5-test-unknown-ids-and-a-dead-api-before-deploying)
*   [What Each Test Proves](#what-each-test-proves)
*   [Step 6: Deploy, Attach, and Verify Inside the Agent](#step-6-deploy-attach-and-verify-inside-the-agent)
*   [Prove the Hosted Copy Works](#prove-the-hosted-copy-works)
*   [Attach It and Run the Acceptance Test](#attach-it-and-run-the-acceptance-test)
*   [When the Agent Ignores the Tool](#when-the-agent-ignores-the-tool)
*   [The Failures That Cost the Most Time](#the-failures-that-cost-the-most-time)
*   [Readiness Checklist Before the Agent Gets This Tool](#readiness-checklist-before-the-agent-gets-this-tool)
*   [Sources](#sources)

Do not copy a [Cloudflare Worker](https://developers.cloudflare.com/workers/) into a Notion project and wait for the deploy to finish. The two products share a name and almost nothing else: a Notion Worker is a TypeScript manifest that registers capabilities on an exported `Worker` instance and runs in a Node.js sandbox Notion operates. There is no `fetch(request, env)` handler and no `wrangler.toml`, so the first failure is a manifest error.

The build below stays inside that shape. You will scaffold a Worker, register a read-only `lookupCustomer` tool with explicit input and output schemas, and call a small REST API with a secret and a timeout. Then you will test an unknown ID and a dead upstream on purpose, and attach the tool to an existing [Custom Agent](https://www.notion.com/help/custom-agents) that can answer “what plan is Ada on?” with a record instead of a guess.

## What a Notion Worker Tool Adds Beyond a Prebuilt MCP Connection

A Custom Agent reaches another system one of two ways. [MCP connections](https://www.notion.com/help/mcp-connections-for-custom-agents) cover a fixed list of apps: Amplitude, Attio, Box, ClickHouse, Figma, GitHub, HubSpot, Intercom, Linear, Mercury, Miro, Mixpanel, n8n, Ramp, Stripe, Sentry, and Wiz. Anything outside it needs [a custom MCP server that an admin enables at a public URL](https://adamtheautomator.com/practical-guide-hosting-managing-remote-mcp/), and ownership is per agent: “Each MCP connection is unique to a single Custom Agent and uses the credentials of the person who authenticated it.”

| Your situation | Better primitive | Why |
| --- | --- | --- |
| The system is one of the 17 pre-configured apps | MCP connection | No code, and per-user authorization is handled |
| You run a hosted MCP server and want its whole tool surface | Custom MCP connection | Admin toggle plus one login per agent |
| The API is internal, or you want one narrow deterministic verb | Worker tool | No public URL, no server to host, and the schema is yours |

### Why This One Belongs in a Tool

A Notion Worker tool takes the third row. Notion describes agent tools as extending “what your Custom Agents can do beyond Notion’s built-in actions and MCP, with capabilities tailored to how your team works” ([Run custom code with Workers](https://www.notion.com/help/run-custom-code-with-workers)). Your RevOps agent needs one verb, the CRM is internal, and [a sync would copy billing records into Notion on a schedule](https://adamtheautomator.com/notion-automation-agents-vs-workers/). A tool answers when the agent asks, and Notion counts every tool call as its own Worker run.

Four boundaries matter: the agent, the schema, the outbound call, and the allowlist.

![Worker tool architecture](https://adamtheautomator.com/wp-content/uploads/publisher/74e5663b1734a537ee0fbe966e7c8e44273cb2eff4cddb91161b2d1acd17736b.jpg)

## Prerequisites for a Notion Worker Tool on a Business Workspace

The gates below stop real builds, in the order you hit them.

*   **Business or Enterprise plan.** Workers are offered there only, and Custom Agents need the same tier.
    
*   **Worker creation and deploy access enabled for you.** Owners can [restrict creation to specific groups](https://www.notion.com/help/run-custom-code-with-workers), and Notion’s troubleshooting treats a failed deploy as a permissions problem first: you may lack deploy access to that workspace even when you can read its pages. The creation restriction is one possible cause.
    
*   **Node.js 22 or newer and npm 10 or newer**, the [Workers quickstart](https://developers.notion.com/workers/get-started/quickstart) baseline.
    
*   **Versions this post was checked against.** Node 22 and npm 10 are the floors above, and the `ntn` CLI and `@notionhq/workers` package move faster than either. No version string was captured from a live workspace, so every command below follows the [documented CLI command reference](https://developers.notion.com/cli/reference/commands) as of October 23, 2026; compare the versions the next block prints against the current docs before you trust a flag name.
    
*   **The Notion CLI, authenticated.** Install with `curl -fsSL https://ntn.dev | bash`, then `ntn login`, which stores the token in your keychain ([CLI overview](https://developers.notion.com/cli/get-started/overview)).
    
*   **An editable Custom Agent**, since attaching a Worker changes its tool configuration.
    
*   **A test API and a read-only key.** Skip it if you have neither: Step 4 builds one locally.
    

```bash
node --version
npm --version
ntn --version
```

A `command not found` on `ntn` means the install ran in another shell, so resolve it before you scaffold anything.

## Step 1: Scaffold the Worker and Lock the Tool Key

Run the scaffold in an empty directory outside any monorepo.

```bash
ntn workers new customer-lookup
cd customer-lookup
```

It produces `src/index.ts`, a TypeScript config, and the runtime’s dependencies ([Workers overview](https://developers.notion.com/workers/get-started/overview)), plus a `.gitignore` covering `.env`.

Commit `workers.json`. It records the Worker ID that `ntn workers deploy` uses to decide which deployed Worker to update, so CI targets the same Worker instead of creating one per push ([Sharing Workers](https://developers.notion.com/workers/guides/sharing-workers)). The tool key in `src/index.ts` is a contract other configuration points at: “If you rename a tool key, existing agent configuration that refers to the old key needs to be updated” ([agent tools guide](https://developers.notion.com/workers/guides/tools)).

## Step 2: Define the Input and Output Schema

The model fills this schema from a conversation, so each field needs a type narrow enough to reject garbage and a description that says when it applies. The `j` builder derives JSON Schema and TypeScript types from one declaration ([schema reference](https://developers.notion.com/workers/reference/schema)). Here is the complete `src/index.ts`.

```typescript
import { Worker } from "@notionhq/workers";
import { j } from "@notionhq/workers/schema-builder";
import { lookupCustomer, UpstreamError, type CustomerSummary } from "./crm";

const worker = new Worker();

type LookupResult = {
  found: boolean;
  reason: "ok" | "not_found" | "missing_identifier";
  customerId: string | null;
  email: string | null;
  name: string | null;
  plan: CustomerSummary["plan"] | null;
  status: CustomerSummary["status"] | null;
  accountUrl: string | null;
  matchedOn: "id" | "email" | null;
};

const customerSummary = j.object({
  found: j.boolean().describe("True when the API returned exactly one customer."),
  reason: j
    .enum("ok", "not_found", "missing_identifier")
    .describe("Stable token for the agent to branch on. Do not infer it from the message text."),
  customerId: j.string().nullable().describe("Canonical customer ID from the API. Null when nothing matched."),
  email: j.string().nullable().describe("Canonical email on the account. Null when nothing matched."),
  name: j.string().nullable().describe("Account owner name. Null when nothing matched."),
  plan: j.enum("starter", "growth", "enterprise").nullable().describe("Current subscription plan."),
  status: j.enum("active", "past_due", "canceled").nullable().describe("Billing status."),
  accountUrl: j.string().nullable().describe("Deep link to the account page."),
  matchedOn: j.enum("id", "email").nullable().describe("Which identifier produced the match."),
});

worker.tool("lookupCustomer", {
  title: "Lookup Customer",
  description:
    "Look up one customer by exact customer ID or email address. Use when someone asks about a single account's plan, billing status, or account link. Do not use it to list or search for several customers.",
  schema: j.object({
    customerId: j
      .string()
      .nullable()
      .describe("Customer ID such as cus_0001. Null when you only have an email address."),
    email: j
      .email()
      .nullable()
      .describe("Exact email address on the account. Null when you only have a customer ID."),
  }),
  outputSchema: customerSummary,
  hints: { readOnlyHint: true },
  execute: async ({ customerId, email }) => {
    if (!customerId && !email) {
      return emptyResult("missing_identifier");
    }

    try {
      const customer = await lookupCustomer({ customerId, email });

      if (!customer) {
        return emptyResult("not_found");
      }

      return {
        found: true,
        reason: "ok",
        ...customer,
        matchedOn: customerId ? "id" : "email",
      };
    } catch (error) {
      if (error instanceof UpstreamError) {
        throw new Error(`Customer lookup unavailable: ${error.message}`);
      }
      throw error;
    }
  },
});

function emptyResult(reason: "not_found" | "missing_identifier"): LookupResult {
  return {
    found: false,
    reason,
    customerId: null,
    email: null,
    name: null,
    plan: null,
    status: null,
    accountUrl: null,
    matchedOn: null,
  };
}

export default worker;
```

### Why Every Field Is Required

The builder marks every property in `j.object()` as required and sets `additionalProperties: false`, so no input is optional. An identifier the caller may not have is declared `.nullable()` and handled inside `execute`, which is why both keys accept `null`. A handler that assumes one is present crashes on the first call that has only the other.

### What the Output Schema Buys You

`outputSchema` makes the platform validate the return value before the agent sees it ([SDK reference](https://developers.notion.com/workers/reference/sdk)), so a plan outside the enum fails the call instead of handing the model a string to paraphrase. Read-only tools “are safe to call repeatedly and can be auto-executed under the default policy,” while a tool without the hint prompts for permission and stalls a scheduled run behind a click.

The second argument to `execute` is a capability context: `context.notion` is a preauthenticated client with the calling agent’s permissions ([Using Notion API from a worker](https://developers.notion.com/workers/guides/api-client)). This tool skips it because the record lives in the CRM.

## Step 3: Make the Outbound Call Without the Credential

Most of a Notion Worker tool’s trustworthiness lives in `src/crm.ts`. Three rules shape it: read the credential from `process.env`, bound the request so a hung CRM cannot hold the turn open, and return a field allowlist rather than the API’s full object.

```typescript
export type CustomerSummary = {
  customerId: string;
  email: string;
  name: string;
  plan: "starter" | "growth" | "enterprise";
  status: "active" | "past_due" | "canceled";
  accountUrl: string;
};

type ApiCustomer = CustomerSummary & {
  id: string;
  billingAddress: string;
};

export class UpstreamError extends Error {}

export async function lookupCustomer(query: {
  customerId: string | null;
  email: string | null;
}): Promise<CustomerSummary | null> {
  const baseUrl = process.env.CRM_BASE_URL;
  const apiKey = process.env.CRM_API_KEY;

  if (!baseUrl || !apiKey) {
    throw new UpstreamError("CRM_BASE_URL or CRM_API_KEY is not configured");
  }

  const url = new URL("/customers", baseUrl);

  if (query.customerId) {
    url.searchParams.set("id", query.customerId);
  } else if (query.email) {
    url.searchParams.set("email", query.email.trim().toLowerCase());
  }

  let response: Response;
  try {
    response = await fetch(url, {
      headers: { authorization: `Bearer ${apiKey}` },
      signal: AbortSignal.timeout(5000),
    });
  } catch (error) {
    throw new UpstreamError(error instanceof Error ? error.message : String(error));
  }

  if (response.status === 404) {
    return null;
  }

  if (!response.ok) {
    throw new UpstreamError(`CRM returned HTTP ${response.status}`);
  }

  const body = (await response.json()) as { data: ApiCustomer | null };

  if (!body.data) {
    return null;
  }

  // Allowlist: billingAddress stays in the CRM and never reaches the model.
  return {
    customerId: body.data.id,
    email: body.data.email,
    name: body.data.name,
    plan: body.data.plan,
    status: body.data.status,
    accountUrl: body.data.accountUrl,
  };
}
```

### Store the Credential With the Worker

Notion encrypts worker secrets at rest and exposes them as environment variables at runtime ([secrets guide](https://developers.notion.com/workers/guides/secrets)).

```bash
ntn workers env set CRM_API_KEY=<read-only-key> CRM_BASE_URL=https://crm.example.com
ntn workers env list
```

`env list` prints key names without values, which catches a typo before a 401 does. Keep local values in `.env`, keep that file out of Git, and commit a `.env.example` with placeholders.

* * *

_**Pro Tip: Run _**`ntn workers env list`**_ after every rotation. A misspelled _**`CRM_API_KEY`**_ shows up as a missing line, which costs less to find than a 401 during a demo.**_

* * *

### What the Call Does When the CRM Breaks

An empty `data` field or a 404 returns `null`, which becomes `found: false`. A 401 or a 500 throws `UpstreamError` with the status in the message, and `execute` rethrows it so the run fails. A connection refusal or a timeout rejects inside `fetch` and never reaches the `response.ok` check, so the wrapper converts those rejections into `UpstreamError` too. An unknown customer is an answer and an unreachable CRM is an incident; folding the second into `found: false` tells a user their account does not exist while the API is down for everyone.

`AbortSignal.timeout(5000)` is a guardrail you own: the Workers [limits reference](https://developers.notion.com/workers/reference/limits) documents run rates, not a per-tool timeout, so pick a bound you can defend.

## Step 4: Stand Up a Synthetic Customer API to Test Against

You need an API that misbehaves on request and never touches a real customer. This server serves three fabricated records, requires a bearer token, and returns `billingAddress` on purpose so you can prove the allowlist drops it.

```typescript
// src/dev/customer-api.ts
// Synthetic records only. Every value here is fabricated.
import { createServer } from "node:http";

type Customer = {
  id: string;
  email: string;
  name: string;
  plan: "starter" | "growth" | "enterprise";
  status: "active" | "past_due" | "canceled";
  accountUrl: string;
  billingAddress: string;
};

const customers: Customer[] = [
  {
    id: "cus_0001",
    email: "ada@example.com",
    name: "Ada Lovelace",
    plan: "growth",
    status: "active",
    accountUrl: "https://app.example.com/accounts/cus_0001",
    billingAddress: "1 Analytical Way, Testville",
  },
  {
    id: "cus_0002",
    email: "grace@example.com",
    name: "Grace Hopper",
    plan: "enterprise",
    status: "active",
    accountUrl: "https://app.example.com/accounts/cus_0002",
    billingAddress: "2 Compiler Lane, Testville",
  },
  {
    id: "cus_0003",
    email: "alan@example.com",
    name: "Alan Turing",
    plan: "starter",
    status: "past_due",
    accountUrl: "https://app.example.com/accounts/cus_0003",
    billingAddress: "3 Bletchley Road, Testville",
  },
];

const port = Number(process.env.PORT ?? 8787);
const apiKey = process.env.CRM_API_KEY ?? "local-only-key";

createServer((request, response) => {
  const url = new URL(request.url ?? "/", `http://localhost:${port}`);
  const send = (status: number, body: unknown) => {
    response.writeHead(status, { "content-type": "application/json" });
    response.end(JSON.stringify(body));
  };

  if (request.headers.authorization !== `Bearer ${apiKey}`) {
    send(401, { error: "unauthorized" });
    return;
  }

  if (url.pathname !== "/customers") {
    send(404, { error: "not_found" });
    return;
  }

  const id = url.searchParams.get("id");
  const email = url.searchParams.get("email")?.trim().toLowerCase();
  const match = customers.find((customer) => (id ? customer.id === id : customer.email === email));

  send(200, { data: match ?? null });
}).listen(port, () => {
  console.log(`Synthetic CRM listening on http://localhost:${port}`);
});
```

Start it in its own terminal and leave it up.

```bash
node --experimental-strip-types src/dev/customer-api.ts
```

That flag forces Node’s built-in type stripping, so the TypeScript file runs with no build step ([Node’s TypeScript support](https://nodejs.org/api/typescript.html)). Type stripping landed in Node 22.6.0 and is on by default from 22.18.0 and 23.6.0, so on a 22.18 or newer install, `node src/dev/customer-api.ts` works without it. Keep the flag for an older 22.x install; the rest of the commands depend on the `ntn` CLI and the `@notionhq/workers` package.

```bash
curl -s "http://localhost:8787/customers?email=ada@example.com" \
  -H "Authorization: Bearer local-only-key"
```

A match returns `id`, `email`, `name`, `plan`, `status`, `accountUrl`, and `billingAddress`. Every other email returns `{"data":null}`. A request without the token gets a 401, which becomes a failed run.

## Step 5: Test Unknown IDs and a Dead API Before Deploying

Local execution checks schema validation, output, and errors without spending a deploy ([agent tools guide](https://developers.notion.com/workers/guides/tools)).

```bash
ntn workers exec lookupCustomer --local -d '{"customerId":"cus_0001","email":null}'
```

Now the case this post is about: an ID that does not exist.

```bash
ntn workers exec lookupCustomer --local -d '{"customerId":"cus_9999","email":null}'
```

The result is `found: false` with `reason: "not_found"`, so the agent reports a missing customer instead of retrying a query that will never succeed. A malformed email never reaches `execute`.

The upstream failure needs a broken endpoint, so point the tool at a closed port.

```bash
printf 'CRM_BASE_URL=http://127.0.0.1:9\nCRM_API_KEY=local-only-key\n' > .env.broken
ntn workers exec lookupCustomer --local --dotenv .env.broken -d '{"customerId":"cus_0001","email":null}'
```

Port 9 is the discard port, so the fetch fails immediately. Expect a failed run whose message reads `Customer lookup unavailable: fetch failed`, and `ntn workers runs logs <run-id>` keeps the rest ([CLI command reference](https://developers.notion.com/cli/reference/commands)). A CRM that accepts the connection and never answers trips the five-second bound instead, and reads `Customer lookup unavailable: The operation was aborted due to timeout`. A reachable server that returns an error status reads `Customer lookup unavailable: CRM returned HTTP 401` or `Customer lookup unavailable: CRM returned HTTP 500`.

### What Each Test Proves

| Case | Input | Expected result |
| --- | --- | --- |
| Known ID | `{"customerId":"cus_0001","email":null}` | `found: true`, `plan: "growth"`, `matchedOn: "id"`, no `billingAddress` key |
| Case noise in the email | `{"customerId":null,"email":"ADA@Example.com"}` | `found: true`, with `ada@example.com` echoed back |
| Unknown ID | `{"customerId":"cus_9999","email":null}` | `found: false`, `reason: "not_found"` |
| Malformed email | `{"customerId":null,"email":"not-an-email"}` | Rejected by the schema before `execute` runs |
| No identifier at all | `{"customerId":null,"email":null}` | `found: false`, `reason: "missing_identifier"` |
| Dead API | The same ID with `--dotenv .env.broken` | Failed run, message reads `Customer lookup unavailable: fetch failed` |

Those cases collapse into three outcomes the agent has to tell apart, which the diagram below separates.

![Three-branch contract](https://adamtheautomator.com/wp-content/uploads/publisher/4e803a5cf32da7e2d7d66e5d0f2323b7ab073624a4e3049d01be895e1d2ed545.jpg)

## Step 6: Deploy, Attach, and Verify Inside the Agent

Deploying bundles the project and starts serving the capability you registered ([Workers overview](https://developers.notion.com/workers/get-started/overview)).

```bash
ntn workers deploy
```

### Prove the Hosted Copy Works

Confirm the Notion Worker tool exists on the deployed Worker before you touch the agent; both commands below are documented in the [CLI command reference](https://developers.notion.com/cli/reference/commands).

```bash
ntn workers capabilities list
ntn workers exec lookupCustomer -d '{"customerId":"cus_0001","email":null}'
```

The second command runs the hosted copy against the deployed secrets. If it returns `found: false` where the local run returned a record, the deployed `CRM_BASE_URL` or `CRM_API_KEY` is wrong.

### Attach It and Run the Acceptance Test

Open the Custom Agent, go to **Tools & Access**, add a connection, and select the Worker. Each tool it declares can be enabled or disabled there ([SDK reference](https://developers.notion.com/workers/reference/sdk)). The [support routing walkthrough](https://adamtheautomator.com/notion-custom-agents-support-routing/) builds a Custom Agent from an intake database if the agent side of this setup is new. If you did not create the Worker, its owner shares it first: **Can connect** attaches it, while **Full Access** also allows redeploys ([Share a Worker](https://www.notion.com/help/share-a-worker)). Sharing is per person only.

Then ask the agent about a customer who is not in the fixture. A working setup reports that nobody matched; a broken one invents a plan.

* * *

_**Key Insight: An unknown customer and a dead CRM both produce an answer that looks wrong. The run log separates them. _**`found: false`**_ leaves a run you can read; a failed run carries a failed status and a message that reads _**`Customer lookup unavailable:`**_ followed by the cause, so a dead port logs _**`Customer lookup unavailable: fetch failed`**_.**_

* * *

Workers are free during the beta and Worker usage does not count against your credit balance ([pricing help](https://www.notion.com/help/understand-pricing-for-workers), checked October 23, 2026). The $0.0023 per run that Notion publishes is a benchmark for when credits apply; because every tool call counts as its own run, a chatty agent multiplies the run count. Custom Agent actions consume credits according to your workspace’s plan and credit settings. The [agent cost tracking walkthrough](https://adamtheautomator.com/notion-agent-api-cost-tracking/) shows how to read those per-agent counters through the Agent API and gate spending on them.

## When the Agent Ignores the Tool

A tool that deploys cleanly and never gets called is usually a contract problem.

### The Failures That Cost the Most Time

| Symptom | Cause | Fix |
| --- | --- | --- |
| Every call waits for a click and a scheduled run never finishes | `readOnlyHint` is missing | Add `hints: { readOnlyHint: true }` and redeploy |
| The agent answers from Notion pages and never calls the tool | Description too broad, or the title does not name the identifier | Narrow it to one customer by exact ID or email |
| New behavior never appears after a rename | Agent configuration still references the old tool key | Re-add the connection or update the tool selection |
| `rate_limit_error` on a burst of runs | Workspace cap on tool runs and queued webhooks | Read `retryAfterSeconds`, then slow the trigger |
| Deploy rejected during a fast edit cycle | Per-workspace deploy budget | Iterate with `--local` and deploy when it behaves |

The rate-limit row deserves a number. The [limits reference](https://developers.notion.com/workers/reference/limits) puts tool runs and queued webhook executions at 600 per hour with a 60 per minute burst, shared per workspace, and deploys at 100 per day with a 10 per 5 minutes burst. Ten minutes of save-and-deploy eats the five-minute window, which is the argument for `--local`.

One failure mode produces no error: a tool that returns more of the record than the answer needs looks healthy. If `billingAddress` shows up in a reply, the allowlist is not working.

## Readiness Checklist Before the Agent Gets This Tool

The code is the easy half. These checks decide whether a Notion Worker tool is still safe in three months.

| Check | Why it matters |
| --- | --- |
| The CRM credential is read-only | The tool never writes, so its key should not be able to |
| The returned fields are narrower than the API response | The model repeats whatever you hand it, including what you did not mean to share |
| The Worker owner and sharing level are written down | A departing creator can disconnect a tool nobody else can fix |

The last check is organizational and breaks quietly: a departed owner looks like a working system until the next call. Notion’s [sharing model](https://www.notion.com/help/share-a-worker) has no group option, so the owner has to be a named person.

## Sources

Every claim above comes from Notion’s documentation, checked on October 23, 2026. Recheck the pricing and limits pages before you rely on a number; the pricing surface already moved once.

*   [How to write an agent tool](https://developers.notion.com/workers/guides/tools), [Schema and builders](https://developers.notion.com/workers/reference/schema), and [How to manage secrets](https://developers.notion.com/workers/guides/secrets) cover `worker.tool`, the schema rules, `readOnlyHint`, `j.object`, and the `ntn workers env` commands.
    
*   [What are Notion Workers?](https://developers.notion.com/workers/get-started/overview) explains the runtime, and [MCP connections for Custom Agents](https://www.notion.com/help/mcp-connections-for-custom-agents) lists the pre-configured apps.
    
*   [Worker limits](https://developers.notion.com/workers/reference/limits) and the [CLI command reference](https://developers.notion.com/cli/reference/commands) carry the run and deploy rate figures and the full `ntn` command surface, including `capabilities list` and `runs logs`.
    
*   [Understand pricing for Workers](https://www.notion.com/help/understand-pricing-for-workers) and [Run custom code with Workers](https://www.notion.com/help/run-custom-code-with-workers) cover plan gates and credits. On the check date the pricing page read: “During the beta, Workers are free to try on Business and Enterprise plans (including Business trials). Eventually, they’ll require Notion credits.” The [changelog](https://developers.notion.com/page/changelog) is where you confirm nothing moved.
    

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fnotion-worker-customer-lookup%2F&text=Notion%20Workers%3A%20Stop%20Agents%20Guessing%20Customer%20Lookups)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fnotion-worker-customer-lookup%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fnotion-worker-customer-lookup%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/publisher/3f25d9c85b2b81c28cc6c39755dac3c7/423d6296450a064f65dba039d6aec0339ac2ba3b86b2f0ef3706c81ff1e57223.webp)

### [Notion Automation: Pick the Right Tier Every Time](/notion-automation-agents-vs-workers/)

Pick the right Notion automation tier: compare database automations, Custom Agents, and Workers, and split ticket routing to cut credit costs.

![](https://adamtheautomator.com/wp-content/uploads/publisher/3f25d9c85b2b81c4811ed7ed9b290871/d4ebe25fb13196c5dffbe9c4668d57fe178690406552541f4e2f3828e040cc6f.webp)

### [Notion Custom Agents: Automate Your Weekly Project Report](/notion-custom-agents-weekly-report/)

Build a Notion Custom Agent that writes a dated weekly project report from your task database, then test it against an empty week and a repeat run.

![](https://adamtheautomator.com/wp-content/uploads/publisher/3f25d9c85b2b8114a4eaea746fdde119/3e64cba088977a23c019e580855960ecff3338dc2fbb3b87595bece2d6b6a02e.webp)

### [Notion Custom Agents: Automate Support Request Routing](/notion-custom-agents-support-routing/)

Build a Notion Custom Agent that reads each new support request, recommends an urgency, and assigns an owner from a routing directory you maintain.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
