This website uses cookies

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

Welcome to issue #02 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 laid down the foundational brick for NowLink: teaching Claude how to read data from ServiceNow securely and efficiently.

This week we teach Claude how to write. Why giving an LLM write access to an enterprise platform is terrifying, how we built a two-step confirmation engine, and what to do when your PDI lags.

Teaching Claude to Write Safely

On paper, adding write operations looks simple. In practice, it raises the stakes through the roof. Reading data is safe—if the LLM misunderstands a query, you just get a weird answer. But once you give an AI permission to modify data, a single hallucinated value or unverified payload hit production can corrupt real records, scramble ticket priorities, or mess up assignment groups.

Before touch code for POST or PATCH, I had to solve a foundational safety problem: How do we make absolutely sure Claude never writes anything to ServiceNow unless you explicitly give the green light?

In this launch edition of our project newsletter, here is exactly what we are breaking down today:

  • The Two-Step Confirmation Pattern: How we baked a mandatory "preview first" guardrail directly into Claude's tool instructions.

  • Shifting Validation Left: Catching missing fields locally so Claude can explain errors in plain English, plus adding smart auto-fills.

  • Audit Logging: How we keep everything transparent.

  • Why We Won't Support Deletes: The architectural and safety reasons why NowLink will never feature a record destruction tool.

  • The Weekly Gotcha: When your PDI is painfully slow.

The Two-Step Confirmation Pattern 📑

Every single write tool in NowLink is forced through a strict, two-step execution gate.

  1. The Preview: Claude calls the tool with confirm=False. NowLink fetches the current record, checks it against local validation rules, calculates a clean diff of what would change, and returns a safe preview to Claude without ever touching the ServiceNow database.

  2. The Execution: Claude renders this preview nicely for you and asks: "Shall I go ahead?" Only if you explicitly say yes does Claude run the tool a second time, swapping to confirm=True to commit the actual change.

The coolest part? This guardrail isn't just hidden in our backend Python code—it's written clearly right inside the tool descriptions that Claude reads before picking an action. The prompt explicitly tells the model: "Never call with confirm=True on the first attempt."* Because the AI reads these guidelines natively, it respects the safety dance perfectly.

create_record() prompt sample

Creating a New Record

When you ask Claude to spin up a new incident, it walks through the confirmation steps seamlessly:

Create record preview (Step 1)

Create record execution (Step 2)

New Incident record in ServiceNow

Updating a Record

The same pattern applies to updating a record.

Update record preview (Step 1)

Update record execution (Step 2)

Updated record in ServiceNow

Catching Errors Early 🧠

To avoid cryptic API errors, we introduced a local mapping dictionary (TABLE_MANDATORY) to validate requirements before hitting the network. It works brilliantly for standard tables, but we found a major catch with custom tables (u_ prefix):

  • When an unknown custom table is hit, validation queries ServiceNow's sys_dictionary for name={table}.

  • However, sys_dictionary only returns fields defined directly on that specific table. Since custom tables usually extend a parent table like task, mandatory inherited fields (like short_description) are completely missing from that query result.

  • This means our local validation could let an incomplete payload pass, causing the write to fail downstream at the ServiceNow API level.

  • The Solution: In v0.2, this is documented as a known limitation. For v0.3, we're building a dynamic get_mandatory_fields(table) engine that will actively walk the table inheritance tree via sys_db_object to build a complete map.

  • sys_mod_count), matches records to tight table-specific keys, and remaps numeric state indicators to legible plain-text string constants.

ServiceNow REST API error messages can be notoriously vague, often just tossing back generic bad request responses. By moving validation "left" into our own local safety.py module, NowLink blocks invalid payloads before they ever leave your machine. This gives Claude the exact details it needs to tell you what's missing in plain, friendly language.

Preview validation: missing mandatory field

To smooth out the user experience, we also added smart defaults. For example, spinning up a record in the incident table strictly requires a caller_id. Instead of slowing down the chat to ask you for this every single time, the server auto-injects our integrated profile account (nowlink.dev) as a baseline fallback, clearly labeling it in the preview window so you stay in full control.

Local Append-Only Audit Logging 📂

To keep everything transparent, every successful write or update completely bypasses volatile session logs and gets committed to a local, append-only file: ~/.nowlink/logs/writes-YYYY-MM-DD.log.

Transactions are written as clean, single-line JSON strings:

11:45:44 | update_record:preview | params={"table": "incident", "identifier": "INC0010006", "fields": {"impact": "1", "urgency": "1"}, "confirm": false} | result=preview for INC0010006

11:46:19 | update_record | params={"table": "incident", "identifier": "INC0010006", "fields": {"impact": "1", "urgency": "1"}, "confirm": true} | result=updated INC0010006 (sys_id=168e415d93c54f10a638fb9fdd03d68a), 2 field(s) changed

Why NowLink Will Never Have a Delete Tool 🚫

While architecting v0.2, I made a firm design choice: NowLink will never feature a record deletion tool. Period. The reasoning comes down to platform best practices and core AI safety rules:

  • Platform Architecture: Hard deletes are almost always a bad idea in ServiceNow. The entire ecosystem is built around state transitions—you close incidents, cancel changes, and retire items. Deletes bypass lifecycle rules, break database references, and ruin your mandatory audit trails.

  • AI Safety: Deletion is the one thing you can't easily undo. If an LLM runs a field update incorrectly, we can roll it back by checking previous states. A deleted record is permanently gone. If you need to remove something via Claude, the right approach is updating its state to Cancelled or Closed, which is handled cleanly through update_record.

The Weekly Gotcha 🪵

Personal Developer Instances (PDIs) are great, but they are notorious for sleeping, lagging, or throwing unexpected timeouts under load. In early testing, an HTTP timeout would throw a raw exception, causing Claude to report that a write failed—even if ServiceNow had actually processed and saved the record perfectly.

To fix this, we built smart verification routines (_verify_create and _verify_update):

  • If an httpx.TimeoutException hits during a write operation, NowLink catches it and automatically queries the target table for records matching the exact payload signatures within the last two minutes.

  • For updates, it re-fetches the record by its sys_id and runs a field-by-field check.

  • If everything matches up, it recovers gracefully, logs the write, and tells the user everything went through successfully.

def _verify_create(table: str, fields: dict) -> dict:
    """
    Called after a POST timeout on create_record.
    Queries the table for a record matching submitted fields, created in the last
    2 minutes. Returns the raw record if found, raises a clear error if not.

    Uses short_description as the primary match key — present on every ITSM table.
    Falls back to the first field in the submitted dict if short_description is absent.
    """
    from datetime import datetime, timedelta, timezone

    # Build a time-bounded query — records created in the last 2 minutes
    cutoff = datetime.now(timezone.utc) - timedelta(minutes=2)
    cutoff_str = cutoff.strftime("%Y-%m-%d %H:%M:%S")

    # Pick the best field to match on
    match_field = None
    match_value = None
    if "short_description" in fields:
        match_field = "short_description"
        match_value = fields["short_description"]
    else:
        # Use the first non-system field in the submitted dict as fallback
        for k, v in fields.items():
            if not k.startswith("sys_"):
                match_field = k
                match_value = v
                break

    if not match_field:
        raise RuntimeError(
            "Create timed out and no suitable field found to verify whether "
            "the record was created. Check ServiceNow manually."
        )

    query = f"{match_field}={match_value}^sys_created_on>={cutoff_str}"

    params = {
        "sysparm_query": query,
        "sysparm_limit": "1",
        "sysparm_display_value": "all",
        "sysparm_exclude_reference_link": "true",
        "sysparm_order_by_desc": "sys_created_on",
    }

    with httpx.Client(verify=False) as client:
        response = client.get(
            f"{_get_base_url()}/api/now/table/{table}",
            headers=_auth_headers(),
            params=params,
            timeout=REQUEST_TIMEOUT,
        )

    data = _handle_response(response, f"post-timeout verification on {table}")
    results = data.get("result", [])

    if results:
        # Record found — the POST completed despite the timeout
        return results[0]

    raise RuntimeError(
        "Create timed out and no matching record was found in ServiceNow. "
        "The record was likely not created. Please try again."
    )

What's Next: Safe Bulk Operations (v0.3) 🚀

Now that single-record writes are locked down, our next milestone is handling scale. v0.3 introduces bulk operations, giving users the ability to update up to 500 records simultaneously without hammering PDI resources or lagging out the system.

To scale our confirmation pattern securely without flooding your chat with 500 separate prompts, we're introducing a short-lived transient session token engine:

  • A bulk update request will trigger bulk_preview, returning a high-level summary diff alongside a short-lived session token.

  • To commit the batch, bulk_execute must receive that exact token within a strict 5-minute window before it automatically expires.

Have you found clever ways to enforce safety rules within LLM tool setups? Reply to this email and let me know—I read every response!

Until next week, happy coding.

— Tomáš Dolejšek, creator of NowLink

Keep Reading