Execute IBM i (AS/400) CL Command in C# (.NET) with NTi
Introduction
This tutorial shows how to run a CL command on an IBM i (AS/400) from a C# (.NET) application using NTi Data Provider.
CL (Control Language) commands allow you to interact directly with the IBM i system to automate tasks such as creating libraries, managing objects or running batch jobs.
With NTi, these commands can be executed without going through a 5250 interface, directly from modern .NET code, asynchronously as well as synchronously.
Step 1 - Open the connection
Declare a NTiConnection instance and open it. With NTi, async is the normal path (real async end to end), Open() remains available for the synchronous path.
using Aumerial.Data.Nti;
await using var conn = new NTiConnection("server=MY_SYSTEM;user=MY_USER;password=MY_PASSWORD");
await conn.OpenAsync();
💡 Using
await usingensures the connection is automatically closed and released at the end of the block, even if an error occurs.
Step 2 - Run a CL command
Use the ExecuteClCommandAsync() method of NTiConnection (or ExecuteClCommand() for the synchronous path) to run a CL command:
await conn.ExecuteClCommandAsync("CRTLIB LIB(MYLIB) TEXT('My new library')");
As everywhere in NTi, the asynchronous variant accepts a caller CancellationToken: await conn.ExecuteClCommandAsync(command, cancellationToken);
Step 3 - Handle errors with NTiCommandException
When a CL command fails, NTi throws a NTiCommandException carrying the command server return code (ReturnCode) and the complete IBM i message stack in Messages. Each message notably exposes Id (for example CPF2111), Severity and Text, but also Type, File, Library, SubstitutionData and Help.
try
{
await conn.ExecuteClCommandAsync("CRTLIB LIB(MYLIB) TEXT('My new library')");
}
catch (NTiCommandException ex)
{
Console.WriteLine($"Command failed, return code {ex.ReturnCode}");
foreach (var message in ex.Messages)
{
Console.WriteLine($"{message.Id} [{message.Severity}] {message.Text}");
}
}
If the library already exists, the message stack contains for example:
CPF2111 [40] Library MYLIB already exists.
💡
NTiExceptionremains the common base class (deriving fromDbException): a finalcatch (NTiException)also catches SQL errors (NTiSqlException) and network errors (NTiCommunicationException).
Two jobs, two QTEMP, two CURLIB
Each NTiConnection actually opens two jobs on the IBM i:
- the SQL job (
QZDASOINIT) runs everything that goes throughNTiCommand; - the command job (
QZRCSRVS) runsExecuteClCommand,CallProgramandCallServiceProgram.
Each job has its own QTEMP and its own CURLIB: an object created in QTEMP by a CL command is not visible from SQL, and a command changing the command job's CURLIB changes nothing for the SQL job.
// The command job creates a source file in ITS QTEMP
await conn.ExecuteClCommandAsync("CRTSRCPF FILE(QTEMP/DEMO)");
// The SQL job has ITS OWN QTEMP: DEMO does not exist there
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM QTEMP.DEMO";
try
{
await cmd.ExecuteScalarAsync();
}
catch (NTiSqlException ex)
{
Console.WriteLine(ex.Message); // SQL0204: DEMO in QTEMP type *FILE not found
}
To inspect both jobs (handy to find them in WRKACTJOB or in your logs):
Console.WriteLine($"SQL job : {conn.DatabaseJob}");
Console.WriteLine($"Command job : {conn.CommandJob}");The QSYS2.QCMDEXC bridge
To run a CL command inside the SQL job (create an object in the QTEMP seen by SQL, set an OVRDBF that applies to queries, change its CURLIB), go through the QSYS2.QCMDEXC SQL procedure:
// Runs the CL command INSIDE the SQL job (QZDASOINIT)
await using var bridge = conn.CreateCommand();
bridge.CommandText = "CALL QSYS2.QCMDEXC('CRTSRCPF FILE(QTEMP/DEMO)')";
await bridge.ExecuteNonQueryAsync();
// This time the SQL job sees the object: it lives in ITS QTEMP
await using var query = conn.CreateCommand();
query.CommandText = "SELECT COUNT(*) FROM QTEMP.DEMO";
var count = await query.ExecuteScalarAsync();
This bridge has a cost: the command travels through the SQL path (preparing and executing a CALL), and on failure the error surfaces as a generic NTiSqlException, without the detailed message stack of NTiCommandException. Reserve QSYS2.QCMDEXC for commands that absolutely must act on the SQL job's environment. For everything else, ExecuteClCommandAsync is more direct and better diagnosed.
Summary
Complete code (Program.cs of a .NET 8 console application) to run a CL command from .NET with NTi:
using Aumerial.Data.Nti;
await using var conn = new NTiConnection("server=MY_SYSTEM;user=MY_USER;password=MY_PASSWORD");
await conn.OpenAsync();
try
{
await conn.ExecuteClCommandAsync("CRTLIB LIB(MYLIB) TEXT('My new library')");
Console.WriteLine("Library created");
}
catch (NTiCommandException ex)
{
Console.WriteLine($"Command failed, return code {ex.ReturnCode}");
foreach (var message in ex.Messages)
{
Console.WriteLine($"{message.Id} [{message.Severity}] {message.Text}");
}
}What's next?
- Call a program : RPG program call with input/output parameters
- Stored procedure : SQL stored procedure call with Dapper and DataReader
- Call a system API : IBM i system API call via a User Space