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

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.

Notion Automation Pro Tips
2026/5/21 · 23:47
購読 7 件 · コンテンツ 6 件

リサーチノート

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.

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

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