Register multiple tools in one Notion Worker so your Custom Agent can do more than one thing

A Notion Custom Agent wired to a single `worker.tool()` hits a ceiling fast — real PM workflows need both lookup and write operations in the same conversation. This tip shows how to register two `worker.tool()` calls in one `src/index.ts`: a read-only `lookupRoadmapItem` (auto-executes via `readOnlyHint: true`) and a write `flagAsBlocked` (gates on confirmation). The Custom Agent routes between them automatically using LLM function-calling. One `ntn workers deploy` covers both.

リサーチノート

Plan required: Notion Business or Enterprise ($20/user/month minimum). 1
Beta window: Workers are free through August 11, 2026, then billed at approximately $0.0023 per Worker run via Notion Credits. 1
Yesterday's tip showed how to wire up a single worker.tool() — a sprint-database query the Custom Agent can call on demand. Useful. But real PM workflows rarely need just one operation. You want the agent to look up a roadmap item and flag it as blocked in the same conversation, without two separate Workers and two separate deployments to maintain.
Notion Workers supports this directly. A single src/index.ts file can hold multiple worker.tool() calls — each registering an independent tool with its own schema and execution logic. 2 The Custom Agent sees all of them, picks the right one based on each tool's title and description, and either auto-executes or asks for confirmation depending on whether you've set readOnlyHint: true. 3 One deploy, two capabilities.
This tip builds a dual-tool Worker: lookupRoadmapItem (read-only, auto-executes) and flagAsBlocked (write, requires confirmation).

Prerequisites

RequirementDetailsWhere to get it
Notion Business or Enterprise planntn workers deploy is gated to Business+notion.com/pricing
Notion Internal Integration token (ntn_...)Authenticates the Worker to read and write both databasesapp.notion.com/developers → Create integration
Integration capabilitiesRead Content + Update ContentCapabilities tab in the integration settings
Notion CLI (ntn)Scaffolds and deploys Workerscurl -fsSL https://ntn.dev | bash
Node.js 18+Required for local developmentnodejs.org or nvm install 18
Roadmap database ID32-char hex from the database URLnotion.so/workspace/DATABASE_ID?v=…
Roadmap database shared with the integrationBoth databases need explicit connectionDatabase → ··· → Connections → select integration

How the agent picks which tool to call

The Custom Agent uses LLM function-calling to route requests: it reads each tool's title, description, and every schema field's .describe() annotation, then decides which tool to invoke and generates the typed arguments. 3 No routing table, no explicit branching code — tool selection is entirely driven by the text you write in those three places.
Two behaviors differ based on hints:
  • hints: { readOnlyHint: true } — the agent auto-executes without asking the user for confirmation. Correct for lookups. 4
  • No hints set — the agent treats the tool as a write operation and requests user confirmation before running. 4
This means a read tool and a write tool can coexist in one Worker with different execution behaviors. Thomas Wiegold, an independent developer who shipped a two-tool Shopify Worker four days after the Developer Platform launched, described the result: "One worker, three capabilities. A managed Notion database that holds Shopify orders, a sync that keeps it current every fifteen minutes, and two tools the agent can call." 5 The pattern works for PM toolkits the same way.
A note on scale: a community-built OpenAPI-to-Workers generator (RavenRepo/notion-workx) documents a platform-enforced ceiling of 100 capabilities per Worker. 6 For a dual-tool Worker you're nowhere near that limit.

Step-by-step: build the dual-tool Worker

Step 1: Scaffold the project

ntn workers new roadmap-agent-tools
cd roadmap-agent-tools

Step 2: Write both tools in src/index.ts

Replace the scaffolded file with the following. Both tools share one Worker instance and one export default.
import { Worker } from "@notionhq/workers";
import { j } from "@notionhq/workers/schema-builder";

const worker = new Worker();
export default worker;

// ── Tool 1: read-only lookup ──────────────────────────────────────────────
worker.tool("lookupRoadmapItem", {
  title: "Look up roadmap item",
  description:
    "Returns the title, status, owner, and target quarter for a roadmap item. " +
    "Call this when the user asks about the current state or details of a specific roadmap item.",
  schema: j.object({
    itemName: j
      .string()
      .describe("The exact name of the roadmap item as it appears in Notion."),
  }),
  hints: { readOnlyHint: true },
  execute: async ({ itemName }, context) => {
    const databaseId = process.env.ROADMAP_DATABASE_ID!;

const response = await context.notion.databases.query({
      database_id: databaseId,
      filter: {
        property: "Name",
        title: { equals: itemName },
      },
      page_size: 1,
    });

if (response.results.length === 0) {
      return { found: false, itemName };
    }

const page = response.results[0] as any;
    const props = page.properties;

return {
      found: true,
      itemName,
      status: props?.Status?.status?.name ?? "Unknown",
      owner: props?.Owner?.people?.[0]?.name ?? "Unassigned",
      targetQuarter: props?.["Target Quarter"]?.select?.name ?? "Not set",
      notionUrl: page.url,
    };
  },
});

// ── Tool 2: write — flags an item as Blocked ─────────────────────────────
worker.tool("flagAsBlocked", {
  title: "Flag roadmap item as Blocked",
  description:
    "Updates the Status of a roadmap item to 'Blocked' and optionally writes a reason to the Blocker field. " +
    "Call this only when the user explicitly asks to mark or flag an item as blocked.",
  schema: j.object({
    itemName: j
      .string()
      .describe("The exact name of the roadmap item to flag."),
    blockerReason: j
      .string()
      .nullable()
      .describe(
        "Short explanation of what is blocking the item. Pass null if no reason is provided."
      ),
  }),
  execute: async ({ itemName, blockerReason }, context) => {
    const databaseId = process.env.ROADMAP_DATABASE_ID!;

// Find the page ID first
    const queryResponse = await context.notion.databases.query({
      database_id: databaseId,
      filter: {
        property: "Name",
        title: { equals: itemName },
      },
      page_size: 1,
    });

if (queryResponse.results.length === 0) {
      return { updated: false, reason: "Item not found in database." };
    }

const pageId = queryResponse.results[0].id;

const properties: Record<string, unknown> = {
      Status: { status: { name: "Blocked" } },
    };

if (blockerReason) {
      properties["Blocker"] = {
        rich_text: [{ text: { content: blockerReason } }],
      };
    }

await context.notion.pages.update({
      page_id: pageId,
      properties,
    });

return { updated: true, itemName, status: "Blocked", blockerReason };
  },
});
Match your property names. "Status", "Owner", "Target Quarter", and "Blocker" must match your database's property names exactly. Check them in your database settings before running.

Step 3: Test each tool locally

# Create .env with credentials
echo "NOTION_TOKEN=ntn_..." > .env
echo "ROADMAP_DATABASE_ID=&lt;your-32-char-id&gt;" >> .env

# Test the read tool — no writes to Notion
ntn workers exec lookupRoadmapItem --local -d '{"itemName":"Q3 API Gateway Redesign"}'

# Test the write tool — this WILL update Notion, so use a test item
ntn workers exec flagAsBlocked --local -d '{"itemName":"Test Item","blockerReason":"Dependency on infra team"}'
A successful lookup returns:
{
  "found": true,
  "itemName": "Q3 API Gateway Redesign",
  "status": "In Progress",
  "owner": "Priya Nair",
  "targetQuarter": "Q3 2026",
  "notionUrl": "https://notion.so/..."
}
The write tool returns { "updated": true, "itemName": "...", "status": "Blocked", "blockerReason": "..." } on success.

Step 4: Deploy

ntn workers env set NOTION_TOKEN=ntn_...
ntn workers env set ROADMAP_DATABASE_ID=&lt;your-32-char-id&gt;
ntn workers deploy
Both tools deploy together in a single command. 7 After deployment, each tool appears independently in your Custom Agent's connection settings — you can enable or disable either one without redeploying. 4

Step 5: Connect to your Custom Agent

Open the Custom Agent's settings, click + Add connection, and select the deployed Worker. Both lookupRoadmapItem and flagAsBlocked appear as separate toggles. Enable both.
Add this to the agent's system prompt:
"You are a roadmap assistant for this team's Notion workspace. When the user asks about the state of a roadmap item, call lookupRoadmapItem. When the user explicitly asks to flag an item as blocked, call flagAsBlocked with the item name and — if provided — the reason. For flagAsBlocked, always confirm the item name with the user before calling."

Expected outcome

Ask: "What's the current status of the API Gateway Redesign?" → the agent calls lookupRoadmapItem automatically, no confirmation prompt, and writes the status summary into the page.
Ask: "Flag the API Gateway Redesign as blocked — infra team dependency." → the agent calls flagAsBlocked, shows a confirmation prompt (because readOnlyHint is absent), and updates the Notion page on approval.
Thomas Wiegold observed this loop in action with his Shopify Worker: "The agent called getCustomerSnapshot, got back a structured payload... and wrote that into the page as a clean summary. Question to answer, maybe four seconds." 5

Gotchas

Description overlap causes mis-selection. When two tools have similar descriptions, the agent may call the wrong one. The Notion docs are explicit: "Avoid descriptions that are too broad, such as 'Run support operations'. A narrow description makes the tool easier for the agent to choose correctly." 3 The descriptions above deliberately use different verbs ("returns" vs. "updates") and different trigger phrases ("asks about... state" vs. "explicitly asks to mark or flag"). Wiegold put the stakes plainly: "This took me longer to get right than the rest of the worker combined. The tools." 5
readOnlyHint is advisory, not a permission gate. It controls whether the agent prompts for confirmation; it does not prevent the tool from modifying data if your execute function writes. 4 Keep write operations in tools without readOnlyHint, and keep any actual write logic out of tools marked readOnlyHint: true.
Renaming a tool key breaks existing agent configuration. The first argument to worker.tool() (e.g., "lookupRoadmapItem") is the stable identifier the Custom Agent stores internally. 3 If you rename it after deploying and connecting to an agent, the agent's stored reference goes stale and you'll need to reconfigure the connection.
Each tool call bills as a separate Worker run. A single conversation that calls both lookupRoadmapItem and flagAsBlocked generates two Worker runs — billed separately starting August 11, 2026 at roughly $0.0023 each. 1 For a typical PM using the agent a few times a day, the monthly cost is well under $1, but monitor usage during the free beta with ntn workers runs list to project your post-August bill.
The Notion API limit of 3 requests/second applies per integration. Both tools share the same integration token. 8 The lookup tool makes one database query; the flag tool makes two (query + update). Simultaneous agent calls to both tools in a burst can approach the rate limit. If your team's agent triggers frequently, add a try/catch that respects the Retry-After header on HTTP 429 responses.

このコンテンツについて、さらに観点や背景を補足しましょう。

  • ログインするとコメントできます。