This website uses cookies

Read our Privacy policy and Terms of use for more information.

Welcome to issue #03 of NowLink — a build-in-public newsletter of creating a local MCP server that connects Claude Desktop directly to ServiceNow.

In our last issue, we walked through how NowLink handles single-record writes using a two-step confirmation engine.

This week we take that same idea and scale it up. Claude can now update multiple of ServiceNow records at once.

Teaching Claude to Think in Bulk 🤔

Updating multiple records sounds dangerous. It should sound dangerous. A mis-scoped filter on 500 incidents is a lot harder to undo than a single field change on INC0000055. So before shipping any bulk capability, we had the same question as v0.2 — but louder: how do we make sure Claude never executes a bulk update the user didn't explicitly review and approve?

In this launch edition we cover that topic and more:

  • The Two-Step Confirmation Pattern: How we carried the same guardrail from single-record updates to bulk operations — and why the tool description is the actual safety mechanism, not the code.

  • The Bulk Execution: What we found out that is not documented and why we ignore the batch API response codes (and what we do instead)

  • The Session Token: How NowLink locks a bulk operation at preview time so Claude can't modify what it's about to execute — even if it tries.

  • The Weekly Gotcha: The mandatory field that isn't — and how NowLink fixes what ServiceNow won't.

The Two-Step Confirmation Pattern 📑

Similiar to a single record update, the bulk flow works across two separate conversation turns. That's not a UX decision — it's a safety mechanism.

  • Turn one: the user asks for something like "update all open incidents assigned to the networking team to urgency High." Claude calls bulk_preview, which counts the matching records, fetches a sample, generates a before→after diff for each record in the sample, and returns everything — including a session token. Claude then shows the count and the sample to the user and asks: "Shall I go ahead and update all 42 records?"

  • Turn two: only if the user explicitly says yes — "execute", "go ahead", "do it" — does Claude call bulk_execute with the token. Nothing else.

The tool description makes this explicit. We learned the hard way that without this instruction, Claude treats the two tools as a natural sequence to complete the task. It calls preview, gets the token, and immediately executes — all in one turn, without ever showing the user what it's about to do. The instruction in the tool description is the actual safety enforcement. The code is not enough on its own.

bulk_execute() prompt sample

Updating Multiple records

When you ask Claude to execute an update on mulpile records, it first finds the relevant records and asks for permission to update them.

Bulk update preview

This is indeed correct, let’s confirm in ServiceNow.

Records in ServiceNow before update

So it seems, we can give Claude a blessing to execute.

Bulk update execution

Let’s confirm that the records were updated correctly.

Records in ServiceNow before update

Yes, it works! 🤘

How the Bulk Execution Actually Works 🔍

One HTTP round trip

NowLink uses the ServiceNow Batch API — POST /api/now/v1/batch — which accepts multiple PATCH sub-requests in a single call and processes them server-side. For 50 records, that's one network call instead of 50. The sub-request bodies are base64-encoded JSON, which is a ServiceNow requirement that isn't documented anywhere (obviously).

Result validation - don’t trust, verify

After firing the batch, NowLink ignores the sub-request status codes entirely. PDI transaction timeouts return 500 "Transaction cancelled: maximum execution time exceeded" for sub-requests that actually completed — a false negative that would have been reported as failures if I'd trusted the response.

Instead, NowLink re-runs the original filter after execution and reports the actual remaining count. If it's zero — all records updated. If it's greater than zero — the user knows exactly how many still need a second pass.

The ground truth is always the re-count. Not the batch response.

The Session Token 🔐

The token is the interesting part.

When bulk_preview runs, it stores the complete operation — table, filter, fields to set, record count — in an in-memory dict keyed by a UUID. That UUID is the token. It expires in five minutes. When bulk_execute runs, it reads the operation from the stored state, not from any parameters Claude passes in.

BULK_TOKEN_TTL_MINUTES = 5
_bulk_tokens: dict[str, dict] = {}

This means Claude cannot modify the bulk operation between preview and execute. The filter that was previewed is the filter that gets executed. The fields that were shown to the user are the fields that get written. If Claude tried to pass different parameters to bulk_execute, it couldn't — the tool only accepts a token. The stored state is what runs.

# Generate session token — stores the full preview state
token = str(uuid.uuid4())
expires_at = datetime.now() + timedelta(minutes=BULK_TOKEN_TTL_MINUTES)
_bulk_tokens[token] = {
    "table": table,
    "filters": filters,
    "fields_to_set": fields_to_set,
    "count": count,
    "expires_at": expires_at,
}

Before executing, bulk_execute also re-counts the records matching the original filter. If the count has grown above 500 since the preview — maybe someone ran an import in the meantime — it refuses and asks the user to re-preview with a tighter filter. The hard limit is enforced at both ends, not just at preview time.

# Validate token exists and hasn't expired
stored = _bulk_tokens.get(token)
if not stored:
    return {
        "error": "Token not found. Call bulk_preview first to generate a valid token.",
    }
if datetime.now() > stored["expires_at"]:
    del _bulk_tokens[token]
    return {
        "error": (
            f"Token expired ({BULK_TOKEN_TTL_MINUTES} minute limit). "
            "Call bulk_preview again to generate a fresh token."
        ),
    }

table = stored["table"]
filters = stored["filters"]
fields_to_set = stored["fields_to_set"]
preview_count = stored["count"]

# Re-count before executing — records may have changed since preview
current_count, _ = client_bulk_query(table, filters)
if current_count > 500:
    del _bulk_tokens[token]
    return {
        "error": (
            f"Record count has changed since preview: now {current_count} records "
            f"(was {preview_count}), exceeding the 500-record limit. "
            "Call bulk_preview again with a tighter filter."
        ),
        "count": current_count,
    }

The Weekly Gotcha 🪵

While building v0.3, we also improved how NowLink handles mandatory fields on creates.

The previous approach used a hardcoded dict — TABLE_MANDATORY — listing which fields are required on which tables. It worked for the six I'd hardcoded. It broke for custom tables. And it had a deeper problem that only surfaced during testing.

Here's what we expected: set mandatory = true on task.short_description in the ServiceNow dictionary, and the REST API would reject a POST without it.

That's what "mandatory" means, right?

We set it. Flushed the cache. Posted an incident without a short description.

ServiceNow returned 201. No error. No warning. Record created. What? 😯

It turns out sys_dictionary.mandatory is ignored by the Table API entirely — not just UI Policies. The field exists as a hint to the browser form and to developers reading the schema. It is not enforced at the database level, and GlideRecord — which is what the REST API uses under the hood — does not check it on insert.

The only way to make ServiceNow actually enforce a mandatory field via the API is to create a Data Policy with "Apply to Web Services" checked. That's a separate configuration step, stored in a separate table, that most instances don't have set up for most fields.

NowLink takes a different approach

It walks the table inheritance chain.

incident extends taskshort_description is defined on task, not incident.

Therefore, a direct sys_dictionary query for name=incident won't find it.

NowLink walks sys_db_object upward via the super_class field to build the full ancestry, then queries sys_dictionary with nameIN incident,task — finding mandatory fields defined anywhere in the chain, including ones inherited from parent tables.

Mandatory fields check - console

The validation is honest about what it is. It's not predicting what ServiceNow will reject — the REST API may accept the record anyway. It's NowLink catching a likely problem before the write attempt, so Claude can tell the user clearly what's missing instead of hitting a timeout or creating garbage data.

Validate what ServiceNow won’t.

Mandatory fields check - Claude

What's Next: Flow Designer (v0.4)

Claude will be able to trigger flows by name, pass input variables, and report back on execution status.

  • The API is different — /api/now/flow_api/ rather than the Table API — and there's some research to do on how execution contexts work via REST before writing any code.

  • As always: build first, document as I go.

What would you most want to update in bulk on your ServiceNow instance? Is it incidents? Assignment groups? CI relationships? Reply — it'll shape what I test next.

Until next week, happy coding.

— Tomáš Dolejšek, creator of NowLink

Keep Reading