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.
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.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=Trueto 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_dictionaryforname={table}.However,
sys_dictionaryonly returns fields defined directly on that specific table. Since custom tables usually extend a parent table liketask, mandatory inherited fields (likeshort_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 viasys_db_objectto 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) changedWhy 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.TimeoutExceptionhits 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_idand 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_executemust 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

