Here's how to do it (sample code to transfer files over iris connection):

/// Get IRIS connection object
/// set iris = ##class().GetIRIS()
ClassMethod GetIRIS() As %Net.DB.Iris
{
    Set host = "host"
    Set port = 1972
    Set ns = "%SYS"
    Set user = "_SYSTEM"
    Set pass = "***"
    Set connection = ##class(%Net.DB.DataSource).CreateConnection(host, port, ns, user, pass)
    Set iris = connection.CreateIris()
    Quit iris
}

/// Transfer one file from sourceFile to targetFile on iris connection.
ClassMethod Transfer(iris As %Net.DB.Iris, sourceFile As %String, targetFile As %String) As %Status
{
    Set sc = $$$OK
    Try {
        Set stream = ##class(%Stream.FileBinary).%New()
        Do stream.LinkToFile(sourceFile)
        Set var = "%stream"
        Do iris.ClassMethodVoid($classname(), "InitStream", var, targetFile)
        While 'stream.AtEnd {
            Set chunk = stream.Read($$$MaxStringLength-1000)
            Do iris.ClassMethodVoid($classname(), "WriteStream", var, chunk)
        }
        Do iris.ClassMethodVoid($classname(), "SaveStream", var, ##class(%File).Attributes(sourceFile))
    }
    Catch ex{
        Do ##class(%SYS.System).WriteToConsoleLog("SuperServer Copy failure in Transfer:" _ ex.DisplayString())
        Throw ex

    }

    Quit sc
}

/// Initialize stream for subsequent write requests and place it in var.
/// var must be a global variable (start form %) 
/// file is created or truncated if already exists
ClassMethod InitStream(var As %String, file As %String)
{
    Try {
        Do ##class(%File).Truncate(file)
        Set stream = ##class(%Stream.FileBinary).%New()
        Do stream.LinkToFile(file)
        Set @var = stream
    } Catch ex{
        Do ##class(%SYS.System).WriteToConsoleLog("SuperServer Copy failure in InitStream: " _ ex.DisplayString())
        Throw ex
    }
}

/// Wrile string into a stream initialized by InitStream 
ClassMethod WriteStream(var As %String, string As %String)
{
    Try {
        Do $method(@var, "Write", string)
    } Catch ex{
        Do ##class(%SYS.System).WriteToConsoleLog("SuperServer Copy failure in WriteStream: " _ ex.DisplayString())
        Throw ex
    }
}

/// Save stream initialized by InitStream. 
/// Optionally sets file attributes.
ClassMethod SaveStream(var As %String, attributes As %String = "")
{
    Try {
        Set sc = $method(@var, "%Save")
        Set file = $property(@var, "Id")
        Kill @var
        Do:attributes'="" ##class(%File).SetAttributes(file, attributes)
    } catch ex {
        Do ##class(%SYS.System).WriteToConsoleLog("SuperServer Copy failure in Savetream: " _ ex.DisplayString())
        Throw ex
    }
}

Use ClassMethodValue to get a scalar value back. Use json for complex type transfer as objects are not supported. There are other methods corresponding to APIs in other languages.

Also please note that this class (technically only the callee *Stream methods but save yourself a headache and just copy the entire class) needs to be present on both nodes.

Finally, remember that callee methods must produce no stdout/stderr writes, since the io is bound to the iris connection itself and it cannot disambiguate stdout.

I think projecting as an attribute is enough. Here's an example:

Class Utils.Message Extends (%RegisteredObject, %XML.Adaptor)
{

Parameter XMLNAME = "ID";

Property scope As %String(XMLPROJECTION = "ATTRIBUTE");

}

DTL:

Class Utils.DTL Extends Ens.DataTransformDTL
{

XData DTL [ XMLNamespace = "http://www.intersystems.com/dtl" ]
{
<transform sourceClass='Ens.Request' targetClass='Utils.Message' create='new' language='objectscript' >
<assign value='"Message"' property='target.scope' action='set' />
</transform>
}

/// do ##class(Utils.DTL).Test()
ClassMethod Test()
{
	set source = ##class(Ens.Request).%New()
	#dim target As Utils.Message
	set sc = ..Transform(source, .target)
	do target.XMLExportToString(.xml)
	w xml, !
}

}

Results in:

>do ##class(Utils.DTL).Test()

<ID scope="Message"></ID>

My recommended approach is to call routines in a silent mode if at all possible, or to do minimal modifications to add silent mode. But here's how you can work with read using input redirection:

ClassMethod Test() [ ProcedureBlock = 0 ]
{
    set tOldIORedirected = ##class(%Device).ReDirectIO()
    set tOldMnemonic = ##class(%Device).GetMnemonicRoutine()
    set tOldIO = $io
    try {
        set str=""
        //Redirect IO to the current routine - makes use of the labels defined below
        use $io::("^"_$ZNAME)

        //Enable redirection
        do ##class(%Device).ReDirectIO(1)

        set x = ..MyLegacyRoutine()
    } catch ex {
        do ex.Log()
    }

    //Return to original redirection/mnemonic routine settings
    if (tOldMnemonic '= "") {
        use tOldIO::("^"_tOldMnemonic)
    } else {
        use tOldIO
    }
    do ##class(%Device).ReDirectIO(tOldIORedirected)
    
    w !,"x is: ",x,!
    w "Routine wrote to device: ", str

    //Labels that allow for IO redirection
    //Read Character
rchr(time)  quit "a"
    //Read a string
rstr(len,time) quit "xyz"
    //Write a character - call the output label
wchr(s)      do output($char(s))  quit
    //Write a form feed - call the output label
wff()        do output($char(12))  quit
    //Write a newline - call the output label
wnl()        do output($char(13,10))  quit
    //Write a string - call the output label
wstr(s)      do output(s)  quit
    //Write a tab - call the output label
wtab(s)      do output($char(9))  quit
    //Output label - this is where you would handle routine device output.
    //in our case, we want to write to str
output(s)    set str=str_s   quit
}

ClassMethod MyLegacyRoutine()
{
    read "Input x: ",x
    write "Hello!"
    return x
}

}

 It outputs:

x is: xyz
Routine wrote to device: Input x: Hello!

The easiest way would be to run on the first day of a month and send results for a previous month.

Another way you can do it is to create a schedule which runs 9:00 am to 9:30 am every day (or 28-31) and checks if this is a last day of a month before doing anything.

Schedule is limited to Max String length, so you can also generate a very long schedule once. For example this code would produce a schedule for the next 10 years:

/// date - start date. Must be the last day of the month.
/// months - how many months to generate
ClassMethod Test(date = "2024-09-30", months = 120)
{
	while $i(months,-1)>=0 {
		set year = $system.SQL.DATEPART("year", date)
		set month = $tr($j($system.SQL.DATEPART("month", date), 2), " ", 0)
		set day = $system.SQL.DATEPART("day", date)
		write $$$FormatText("START:%1-%2-%3T09:00:00,STOP:%1-%2-%3T09:30:00,", year, month, day)
		set date = $system.SQL.DATEADD("day", -1, $system.SQL.DATEADD("month", 1, $system.SQL.DATEADD("day", 1, date)))
	}
}
 
Schedule

If you're okay with February 28th for leap years a schedule can be simplified to 12 entries with * in the year.

If all of the above is not an option create a separate Business Service which runs on a first day of a month, calculates last day of a month and sets a correct schedule for BO.