Find your people. Pick a challenge. Ship something real. The CreatorCon Hackathon is coming to the Community Pavilion for one epic night. Every skill level, every role welcome. Join us on May 5th and learn more here.

Luis Estéfano
ServiceNow Employee

 

AI Search · Custom Apps · Ticket Deflection

Reducing Duplicate Tickets with AI Search

Duplicate tickets are a quiet productivity tax. This article walks through a lightweight pattern that catches duplicates before they are created — as the user types a short description (or any other field), an AI Search query runs in the background and any matching tickets surface in a modal where the user can either open the existing record or follow it for updates. The pattern works on any custom ticket-style table.

Author
Luis Estéfano
Applies to
AI Search · AI Search Assist · Custom Applications
Release
Washington DC and later
 


1. What the user sees

The flow is intentionally minimal — it sits inside the standard new-record form and never blocks the user. As the user types into the short_description field, a Client Script fires a GlideAjax call that runs an AI Search across existing tickets.

If matches come back, a modal opens listing the candidate tickets — number, title, and per-row View / Follow actions. The user can open a match in a new workspace tab, or click Follow to be added to the ticket's watch_list without leaving the new-ticket form. If no matches exist, the modal does not appear.

Why this works: the user is never told "do not create this ticket". They are simply shown what already exists and given a one-click way to subscribe to it. Deflection happens because the easier path becomes following rather than creating.


2. The dialog in action

Here is what the user actually sees the moment AI Search returns matches. The new-ticket form stays in the background, dimmed but not dismissed — the modal appears as a contextual layer rather than a page navigation.

LuisEstfano_0-1777286796417.png

The interaction is designed to feel optional and non-blocking — the AI banner frames it as an assistive suggestion, the Follow button shows three visual states (idle, following, hover), and the close button is always one click away with no confirmation prompt.


3. Architecture overview

Four moving parts make the pattern work — each maps to a single artefact in the platform:

[ New Ticket Form ]
|
| onChange(short_description)
v
[ Client Script ] --GlideAjax--> [ Script Include: doAISearch() ]
|
| AISASearchUtil.search()
v
[ AI Search Profile + Source ]
|
v
Returns matching sysIds
+-----------------------------+
v
[ g_modal.showFrame() ]
|
v
[ UI Page: STL_Similar_Tickets ]
|
+--> View (opens record in workspace)
+--> Follow (GlideAjax -> updates watch_list)
AI Search configuration — profile, source, RP config
Script Include — client-callable wrapper for AISASearchUtil
Client Script — onChange listener that opens the modal
UI Page — renders results with View / Follow actions


4. Keyword search vs. AI Search

Why not just LIKE-match the short description?
Keyword Match
Fast but brittle
Encoded queries on short_description only fire on literal substring matches. Synonyms, paraphrases and translations are missed. Maintenance grows with every new phrasing.
AI Search
Semantic and multilingual
Embeddings catch "VPN keeps disconnecting" against "Cannot stay connected to corporate network". Stop-word dictionaries cover EN, ES, FR, DE, IT, NL, PL, KO and more out of the box.
The trade-off: AI Search needs an indexed source (a few minutes of indexing time on a populated table) and a Search Profile. Once configured, results are sorted by relevance score — the modal shows the strongest candidates first instead of whatever happens to match alphabetically.


5. AI Search configuration

AI Search needs three records configured against your ticket table — x_yourapp_ticket in the steps below. Navigate to AI Search > Sources > Indexed Sources and create the source, then create a matching Search Profile and an aisa_rp_config record:

Field Value
Source table x_yourapp_ticket
Embedding model E5FT (default)
Field mapping — short_description map_to: title
Field mapping — description map_to: text
aisa_rp_config sys_ids Store both in x_yourapp.ai_search.configurationavoids hard-coded sys_ids
Tip: store both sys_ids in a single system property with a comma-separated value. Update sets carry sys_ids forward, but reading from a property keeps the Script Include portable across environments and self-documenting in code review.


6. The Script Include

A small client-callable Script Include wraps AISASearchUtil and reads the configuration from system properties. Mark it client_callable = true and grant cross-scope execute access to AISASearchUtil:

var Ticket_AI_Utils = Class.create();
Ticket_AI_Utils.prototype = Object.extendsObject(global.AbstractAjaxProcessor, {

doAISearch: function() {
var searchTerm = this.getParameter('sysparm_search_term');
var cfg = gs.getProperty('x_yourapp.ai_search.configuration', '').split(',');

var data = {
rpSysId: cfg[0],
searchContextConfigId: cfg[1],
searchTerm: searchTerm
};

return JSON.stringify(new global.AISASearchUtil().search(data));
},

followTicket: function() {
return this._toggleWatchList(this.getParameter('sysparm_sys_id'), true);
},

unFollowTicket: function() {
return this._toggleWatchList(this.getParameter('sysparm_sys_id'), false);
},

_toggleWatchList: function(recordId, addUser) {
var userId = gs.getUserID();
var gr = new GlideRecord('x_yourapp_ticket');
if (!gr.get(recordId)) return false;

var list = (gr.getValue('watch_list') || '').split(',').filter(Boolean);
var idx = list.indexOf(userId);

if (addUser && idx === -1) list.push(userId);
if (!addUser && idx > -1) list.splice(idx, 1);

gr.setValue('watch_list', list.join(','));
gr.autoSysFields(false);
gr.setWorkflow(false);
gr.update();
return true;
},

type: 'Ticket_AI_Utils'
});
Why bypass auto-system-fields and workflow: adding yourself to a watch list should not generate audit noise or fire business rules. A "Notify watchers" rule that triggers when the user adds themselves is an awkward bug to discover in production.


7. The Client Script

An onChange Client Script on the new-ticket form's short_description field — only fires for new records, not on edits:

function onChange(control, oldValue, newValue, isLoading) {
if (isLoading || newValue === '' || oldValue === newValue || !g_form.isNewRecord())
return;

var ticketId = g_form.getUniqueValue();
var ticketNum = g_form.getValue('number');
var shortDesc = g_form.getValue('short_description');

var ga = new GlideAjax('x_yourapp.Ticket_AI_Utils');
ga.addParam('sysparm_name', 'doAISearch');
ga.addParam('sysparm_search_term', shortDesc);
ga.getXMLAnswer(handleResponse);

function handleResponse(response) {
if (!response) return;
var parsed = JSON.parse(response);
if (parsed.status !== 200) return;

var results = parsed.data.search.searchResults;
if (!results || !results.length) return;

var sysIds = results.map(function(r) { return r.sysId; }).join(',');

g_modal.showFrame({
title: 'Similar tickets found',
url: 'x_yourapp_STL_Similar_Tickets.do' +
'?sysparm_similar=' + sysIds +
'&sysparm_ticket_id=' + ticketId +
'&sysparm_ticket_number=' + ticketNum,
size: 'lg',
height: '550px',
width: '900px'
});
}
}


8. The UI Page

Two implementation principles
Server-side
Single g:evaluate, not loops
Resolve the full result set in a single Jelly evaluation block and pass it to the template. Looping GlideRecord queries inside Jelly is slow and brittle.
Client-side
Toggle button state immediately
Update the Follow button's class and label before the GlideAjax call returns. The user gets instant feedback and the network call stays asynchronous.

The Follow toggle handler is short and reads naturally:

function toggleFollow(sysId) {
var btn = document.getElementById('follow_' + sysId);
var isFollowing = btn.classList.contains('following');

// Update UI first - feels instant
btn.classList.toggle('following');
btn.innerHTML = isFollowing
? '<span class="icon-star-empty"></span> Follow'
: '<span class="icon-star"></span> Following';

// Then persist
var ga = new GlideAjax('x_yourapp.Ticket_AI_Utils');
ga.addParam('sysparm_name', isFollowing ? 'unFollowTicket' : 'followTicket');
ga.addParam('sysparm_sys_id', sysId);
ga.getXML();
}


9. Implementation Checklist

1    Cross-scope privileges in place?
      If your custom app cannot call global.AISASearchUtil, the GlideAjax response will be empty. Add a Cross Scope Privilege with operation execute.
2 Has the Search Source finished indexing?
After creating the source, AI Search needs minutes to build the index on a populated table. Empty results immediately after configuration usually mean indexing is incomplete.
3 Is the onChange handler guarded?
Always check isLoading and g_form.isNewRecord() — otherwise opening any existing ticket triggers the modal.
4 Are watch-list updates silenced?
Use autoSysFields(false) and setWorkflow(false) on the follow update to avoid notification noise.
5 Are sys_ids externalised?
Move the Record Producer Configuration sys_id and Search Application Configuration sys_id into a system property — portable across environments and self-documenting.


10. Typical Errors to Watch For

When AI Search Assist or the surrounding plumbing is misconfigured, the failure modes are quiet — the modal simply never appears, or it appears empty. These are the patterns to recognise in browser console and server logs.

EMPTY RESPONSE GlideAjax returns null or undefined
doAISearch result: null
TypeError: Cannot read properties of null (reading 'data')

What it means: the Script Include executed but the call to global.AISASearchUtil was blocked at the cross-scope boundary. The script silently returned undefined, which serialises to null over GlideAjax. Fix by adding a Cross Scope Privilege record granting execute on AISASearchUtil from your application scope.

EMPTY RESULTS searchResults array is consistently empty
{ "status": 200, "data": { "search": { "searchResults": [] } } }

What it means: the API responded successfully but no documents matched. Two common causes — first, the indexed source has not finished building yet (check AI Search > Indexed Sources for index status). Second, the field mappings on the source do not include the field your users are searching against. Verify short_description is mapped to title with the correct map_to attribute.

Pattern to recognise: if your team's duplicate-ticket rate is high in a custom application, the fix is rarely better training or stricter validation — it is giving the user something better to do at the moment of creation. AI Search makes that "something better" cheap to build, and the patterns above prevent the most common rollout pitfalls.


11. Adapting the pattern

The same architecture extends well beyond ticket deflection. The shape is always type → search → modal → action — once the wiring is in place, swapping the source and the action is a small change:

KB deflection on the portal — source becomes kb_knowledge
Account dedup on lead forms — surface possible matches before insert
Related changes during incident creation — surface root-cause candidates
 
#servicenow #ai-search
#ai-search-assist #ticket-deflection #custom-apps #duplicate-prevention #client-script #ui-page
#scoped-applications

If this article was useful, please consider marking it as helpful. Feedback is always welcome.

Kind regards — Luis Estéfano
Version history
Last update:
yesterday
Updated by:
Contributors