An AI Agent for NetSuite
NetSuite is one of the largest, most complex, and most expensive software systems on the planet. However, at least so far, the only AI they've implemented is the ability to generate content, in addition to OCR capabilities to scan invoices. It does not come with AI Agent capabilities, allowing you to automate tasks in the system itself. However, we've got you!
In the above video I demonstrate how you can create an AI Agent that integrates with your NetSuite account, to automate tasks such as searching for contacts, customers, or any other object NetSuite contains. In addition, it allows you to update, create, and delete objects - While also of course integrating perfectly with any other AI function we have, such as sending emails, scrape websites, or perform web searches.
This allows us to deliver AI chatbots similarly to ChatGPT that acts as AI agents on your NetSuite account, 100% without coding. Examples of prompts you could use can be found below.
- "Find a contact named John Doe and return his phone number"
- "Find what company John Doe works for, and create a new invoice for them"
- "Search for Jane working for a company named 'Acme, Inc.' and send her an email telling her I'm late for our meeting"
- "How many invoices did we send in July of 2024 and what was the total amount we invoiced for?"
How it works
The module contains several workflows, that are as follows;
- netsuite-create-record - Create a new object of the specified [type] with the specified [object] values.
- netsuite-get-record - Returns the specified [id] record of the specified [type].
- netsuite-list-records - Lists objects of the specified [type], optionally apply filtering and paging using [q], [limit] and [offset]. Notice, this workflow only returns IDs of records and not the actual records themselves.
- netsuite-get-records - Returns the actual records for the specified [type] to caller, including every field on records. This endpoint allows for paging using [limit] and [from] arguments.
- netsuite-update-record - Updates the specified [id] record with the specified [object] values.
- netsuite-delete-record - Deletes the specified [id] object of the specified [type].
- netsuite-query - Executes the specified [q] SuiteQL query and returns the result to caller.
- netsuite-openapi-spec - Returns the OpenAPI specification for the specified [type] to caller.
- netsuite-schema-spec - Returns the JSON schema specification for the specified [type] to caller.
By combining these into either your system instruction, or adding these as RAG data into your training material, we can instruct OpenAI about how to execute the above workflows towards your NetSuite account, allowing OpenAI to "understand" how to interact with NetSuite on your behalf.
API endpoints
In addition to the above workflows this project also contains the following HTTP API endpoints.
- POST api/record - Creates a new record wrapping the above netsuite-create-record workflow.
- GET api/record - Returns the specified record wrapping the above netsuite-get-record workflow.
- GET api/list - Returns a list of IDs wrapping the above netsuite-list-records workflow. Notice, this endpoint only returns IDs of records, and not any additional fields from your records.
- PATCH api/record - Updates the specified record wrapping the above netsuite-update-record workflow.
- DELETE api/record - Deletes the specified record wrapping the above netsuite-delete-record workflow.
- GET api/query - Executes the specified query wrapping the above netsuite-query workflow.
- GET api/openapi-spec - Returns the OpenAPI specification for the specified type wrapping the above netsuite-openapi-spec workflow.
- GET api/schema-spec - Returns the JSON schema specification for the specified type wrapping the above netsuite-schema-spec workflow.
These API endpoints are just wrappers around the above workflows. See the documentation for the associated workflows to understand their arguments.
Configuration
You will need to supply your private RSA key to the system such as the following illustrates.
-----BEGIN PRIVATE KEY-----
MIIG/wIBADANBgkqhkiG9w0BAQEfgh86DFgfggblAgEAAoIBgQD4EqwERtrtO6Lk
... snipped ...
-----END PRIVATE KEY-----
The above need to exists in a file with the path of "/etc/netsuite/private-key.txt". In addition you'll need the following somewhere in your configuration section.
{
"magic": {
"netsuite": {
"account-id": "ACCOUNT_ID_HERE",
"certificate-id": "CERTIFICATE_ID_HERE",
"consumer-key": "CONSUMER_KEY_HERE"
}
}
}
This allows the module to create an access token, which is needed to invoke API endpoints, that returns data from your account.
Implementation
The system relies upon a dynamically created slot that creates an access token. The entire contens of this file is included below.
/*
* Copyright (c) Thomas Hansen, 2021 - 2023 thomas@ainiro.io.
*/
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Linq;
using magic.node;
using magic.node.extensions;
using magic.signals.contracts;
namespace magic.netsuite
{
/*
* [netsuite.get-access-token] slot returning an access token
* for the specified NetSuite account.
*/
[Slot(Name = "netsuite.get-access-token")]
public class GenerateToken : ISlotAsync
{
// Needed to synchronise invocations.
static readonly SemaphoreSlim _semaphore = new(1, 1);
/*
* Yes, this looks dubious, but dynamically compiled slots cannot
* use dependency injected arguments, so we're kind of left with this
* way of dealing with things.
*
* However, the only thing that can in theory make this stop working
* is DNS updates, at which point it will stop working. It's a small
* risk, easily fixed by recycling the application, and probably
* happens less than once per year, if ever.
*/
readonly static HttpClient _httpClient = new HttpClient();
// Buffers for cache.
static DateTime _tokenExpiration;
static string _accessToken;
// Implementation of interface.
public async Task SignalAsync(ISignaler signaler, Node input)
{
// Retrieving arguments to invocation.
var privateKey = input.Children.FirstOrDefault(x => x.Name == "private-key")?.GetEx() ??
throw new HyperlambdaException("No [private-key] supplied to [netsuite.get-access-token]");
var accountId = input.Children.FirstOrDefault(x => x.Name == "account-id")?.GetEx() ??
throw new HyperlambdaException("No [account-id] supplied to [netsuite.get-access-token]");
var certificateId = input.Children.FirstOrDefault(x => x.Name == "certificate-id")?.GetEx() ??
throw new HyperlambdaException("No [certificate-id] supplied to [netsuite.get-access-token]");
var consumerKey = input.Children.FirstOrDefault(x => x.Name == "consumer-key")?.GetEx() ??
throw new HyperlambdaException("No [consumer-key] supplied to [netsuite.get-access-token]");
// House cleaning.
input.Clear();
// Retrieving a valid token and returning to caller.
input.Value = await GetAccessToken(
accountId,
privateKey,
certificateId,
consumerKey);
}
#region [ -- Private helper methods -- ]
/*
* Returns an access token to caller.
*/
async Task GetAccessToken(
string accountId,
string privateKey,
string certificateId,
string consumerKey)
{
// Thread synchronisation.
await _semaphore.WaitAsync();
try
{
// Checking if we already have a valid token, at which point we return it as is.
if (!string.IsNullOrEmpty(_accessToken) && _tokenExpiration > DateTime.UtcNow)
return _accessToken;
// Creating our root URL.
var rootUrl = $"https://{accountId}.suitetalk.api.netsuite.com/services/rest";
// Retrieving a temporary JWT token.
var jwtToken = GetJwtToken(rootUrl, privateKey, certificateId, consumerKey);
// Executing the HTTP request.
var httpResponse = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Post, rootUrl + "/auth/oauth2/v1/token")
{
Content = new FormUrlEncodedContent(new List>
{
new("grant_type", "client_credentials"),
new("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
new("client_assertion", jwtToken)
})
});
// Retrieving response and ensuring invocation was successful.
var responseJson = await httpResponse.Content.ReadAsStringAsync();
if (!httpResponse.IsSuccessStatusCode)
throw new HyperlambdaException("Could not authenticate to NetSuite", true, 500);
// Parsing JSON and storing token and expiration time.
var json = JToken.Parse(responseJson) as JContainer;
_accessToken = json["access_token"].Value();
_tokenExpiration = DateTime.UtcNow.AddSeconds(int.Parse(json["expires_in"].Value()));
// Returning token to caller.
return _accessToken;
}
finally
{
_semaphore.Release();
}
}
/*
* Returns a JWT token.
*/
static string GetJwtToken(
string rootUrl,
string privateKey,
string certificateId,
string consumerKey)
{
// Keep only the payload of the key.
privateKey = privateKey.Replace("-----BEGIN PRIVATE KEY-----", "");
privateKey = privateKey.Replace("-----END PRIVATE KEY-----", "");
// Importing our RSA key.
byte[] privateKeyRaw = Convert.FromBase64String(privateKey);
var provider = new RSACryptoServiceProvider();
provider.ImportPkcs8PrivateKey(new ReadOnlySpan(privateKeyRaw), out _);
var rsaSecurityKey = new RsaSecurityKey(provider);
// Create signature and add to it the certificate ID provided by NetSuite.
var signingCreds = new SigningCredentials(rsaSecurityKey, SecurityAlgorithms.RsaSha256)
{
Key =
{
KeyId = certificateId
}
};
// Get issuing timestamp.
var now = DateTime.UtcNow;
// Create token.
var tokenHandler = new JwtSecurityTokenHandler();
var tokenText = tokenHandler.WriteToken(tokenHandler.CreateToken(new SecurityTokenDescriptor
{
Issuer = consumerKey,
Audience = rootUrl + "/auth/oauth2/v1/token",
Expires = now.AddMinutes(5),
IssuedAt = now,
Claims = new Dictionary {
{ "scope", new[] { "rest_webservices" } }
},
SigningCredentials = signingCreds
}));
return tokenText;
}
#endregion
}
}
The above C# code is dynamically compiled during installation using the following code.
/*
* Compiles C# code files required to wire up module correctly.
*
* NetSuite contains a couple of custom C# code files which we'll need to correctly wire up things.
*/
// Loading file.
io.file.load:/modules/netsuite/magic.startup/csharp/GenerateToken.cs.raw
// Compiling file into an assembly.
system.compile
references
.:netstandard
.:System.Linq
.:System.Runtime
.:System.Net.Http
.:System.Private.Uri
.:System.ComponentModel
.:System.Private.CoreLib
.:System.Linq.Expressions
.:System.Security.Cryptography
.:Microsoft.IdentityModel.Tokens
.:System.IdentityModel.Tokens.Jwt
.:Microsoft.IdentityModel.Abstractions
.:Newtonsoft.Json
.:magic.node
.:magic.node.extensions
.:magic.signals.contracts
code:x:@io.file.load
assembly-name:dynamic.magic.lambda.netsuite.dll
// Loading assembly as plugin now that we've created it.
system.plugin.load:x:@system.compile
The above process results in a native C# slot we can invoke and encapsulate into a Hyperlambda "action" as follows.
/*
* Returns an access token for NetSuite.
*
* Returns a JWT token that can be used to invoke NetSuite according to your configuration settings.
* Notice, this will read account-id, certificate-id, and consumer-key from your configuration settings,
* in addition to assume your private key exists in the file '/etc/netsuite/private-key.txt'.
*/
.arguments
.icon:http
// Loading private key.
.key
set-value:x:@.key
io.file.load:/etc/netsuite/private-key.txt
/*
* Loading settings from configuration.
*
* Notice, these settings must exist for the system to function.
*/
// Account id
.account-id
set-value:x:@.account-id
config.get:"magic:netsuite:account-id"
validators.mandatory:x:@.account-id
// Certificate id
.certificate-id
set-value:x:@.certificate-id
config.get:"magic:netsuite:certificate-id"
validators.mandatory:x:@.certificate-id
// Consumer key
.consumer-key
set-value:x:@.consumer-key
config.get:"magic:netsuite:consumer-key"
validators.mandatory:x:@.consumer-key
// Invoking slot doing the heavy lifting.
netsuite.get-access-token
private-key:x:@.key
account-id:x:@.account-id
certificate-id:x:@.certificate-id
consumer-key:x:@.consumer-key
// Returning result to caller.
yield
access-token:x:@netsuite.get-access-token
Which again is consumed in our Hyperlambda workflows as follows.
/*
* Creates a new object in NetSuite of the specified [type].
*
* The [type] argument is mandatory and can be any type
* that NetSuite supports, such as for instance:
*
* - account
* - contact
* - customer
* - department
* - deposit
* - employee
* - invoice
* - etc ...
*
* The [object] argument is mandatory and contains a
* key/value list of fields you wish to associate
* with the object.
*/
.arguments
// Mandatory argument declaring the type of entity we want to create.
type:string
// Mandatory argument declaring the actual content of the new entity we want to create.
object:*
.type:public
// Sanity checking invocation.
validators.mandatory:x:@.arguments/*/type
validators.mandatory:x:@.arguments/*/object
validators.mandatory:x:@.arguments/*/object/*
/*
* Returns an access token for NetSuite.
*
* Returns a JWT token that can be used to invoke NetSuite according
* to your configuration settings.
* Notice, this will read account-id, certificate-id, and consumer-key
* from your configuration settings, in addition to assume your private
* key exists in the file '/etc/netsuite/private-key.txt'.
*/
execute:magic.workflows.actions.execute
name:netsuite-get-access-token
filename:/modules/netsuite/workflows/actions/netsuite-get-access-token.hl
arguments
// Creating our idempotency key to prevent replay attacks.
guid.new
// Creating invocation URL.
.url
set-value:x:@.url
strings.concat
.:"https://"
config.get:"magic:netsuite:account-id"
.:.suitetalk.api.netsuite.com/services/rest/record/v1
.:/
get-value:x:@.arguments/*/type
// Parametrizing our payload.
add:x:./*/http.post/*/payload
get-nodes:x:@.arguments/*/object/*
// Invoking our HTTP GET endpoint, now correctly parametrised.
http.post:x:@.url
token:x:--/execute/=netsuite-get-access-token/*/access-token
headers
X-NetSuite-Idempotency-Key:x:@guid.new
payload
// Converts the result to a lambda object.
json2lambda:x:@http.post/*/content
// Sanity checking above invocation.
if
not
and
mte:x:@http.post
.:int:200
lt:x:@http.post
.:int:300
.lambda
// Oops, error ...!!
lambda2hyper:x:@json2lambda
log.error:x:@lambda2hyper
throw:Could not create entity in NetSuite, check your log for details.
// Returning our entity to caller.
return:x:@json2lambda/*
We created Hyperlambda workflows for all CRUD operations in the system, in addition to another workflow encapsulating SuiteQL, allowing you to construct any SuiteQL statement you wish rapidly, and execute towards your account. The SuiteQL workflow looks as follows.
/*
* Executes the specified SuiteQL query towards your NetSuite account.
*
* The [q] argument is mandatory and declares the query you want to execute towards NetSuite.
*/
.arguments
// Mandatory argument being your NetSuite query to execute.
q:string
.type:public
// Sanity checking invocation.
validators.mandatory:x:@.arguments/*/q
/*
* Returns an access token for NetSuite.
*
* Returns a JWT token that can be used to invoke NetSuite according
* to your configuration settings.
* Notice, this will read account-id, certificate-id, and consumer-key
* from your configuration settings, in addition to assume your private
* key exists in the file '/etc/netsuite/private-key.txt'.
*/
execute:magic.workflows.actions.execute
name:netsuite-get-access-token
filename:/modules/netsuite/workflows/actions/netsuite-get-access-token.hl
arguments
// Creating our idempotency key to prevent replay attacks.
guid.new
// Creating invocation URL.
.url
set-value:x:@.url
strings.concat
.:"https://"
config.get:"magic:netsuite:account-id"
.:.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql
// Parametrizing our payload.
add:x:./*/http.post/*/payload
get-nodes:x:@.arguments/*/q
// Invoking our HTTP GET endpoint, now correctly parametrised.
http.post:x:@.url
token:x:--/execute/=netsuite-get-access-token/*/access-token
headers
Prefer:transient
X-NetSuite-Idempotency-Key:x:@guid.new
payload
// Converts the result to a lambda object.
json2lambda:x:@http.post/*/content
// Sanity checking above invocation.
if
not
and
mte:x:@http.post
.:int:200
lt:x:@http.post
.:int:300
.lambda
// Oops, error ...!!
lambda2hyper:x:@json2lambda
log.error:x:@lambda2hyper
throw:Could not create entity in NetSuite, check your log for details.
// Cleaning up redundant stuff.
remove-nodes:x:@json2lambda/*/items/*/*/links
// Returning entities to caller.
yield
items:x:@json2lambda/*/items/*
The last point allows you to rapidly create your own 100% custom workflows based upon SuiteQL, by for instance providing input queries such as.
select id, name, firstname, lastname from contact where firstname like 'john%'
Wrapping up
We haven't wrapped every possible invocation from NetSuite's API, such as uploading file, etc - But we've wrapped everything related to querying objects in your account - In addition to allowing you to rapidly create your own workflows by reusing our existing workflows.
All in all, this should be a valuable addition to your company if you need AI Agent capabilities accessing your NetSuite account, either to automate NetSuite using AI, or because you simply want to save costs on NetSuite by having some users using it indirectly through NetSuite's APIs, allowing you to reduce the number of "seats" associated with your NetSuite installation.
Have a Custom AI Solution
At AINIRO we specialise in delivering custom AI solutions and AI chatbots with AI agent features. If you want to talk to us about how we can help you implement your next custom AI solution, you can reach out to us below.