This guide describes how to implement a new CLI agent that integrates with the Remote Agent server. The Remote Agent system supports pluggable CLI agents through an extensible architecture based on the strategy pattern (TR-10.1).
The Remote Agent server can work with different CLI agents through two approaches:
- Process-based agents – Execute an external command-line program (default implementation)
- Plugin-based agents – Load custom .NET assemblies that implement agent behavior programmatically
Both approaches use the IAgentRunner interface to provide a uniform way to start and manage agent sessions.
The IAgentRunner interface is the foundation for all agent implementations:
namespace RemoteAgent.Service.Agents;
public interface IAgentRunner
{
/// <summary>Starts an agent session.</summary>
/// <param name="command">Agent command (e.g. executable path). May be ignored by plugin runners.</param>
/// <param name="arguments">Optional arguments. May be ignored by plugin runners.</param>
/// <param name="sessionId">Session identifier for logging.</param>
/// <param name="logWriter">Optional session log writer.</param>
/// <param name="cancellationToken">Cancellation.</param>
Task<IAgentSession?> StartAsync(
string? command,
string? arguments,
string sessionId,
StreamWriter? logWriter,
CancellationToken cancellationToken = default);
}The IAgentSession interface represents an active agent session with communication channels:
namespace RemoteAgent.Service.Agents;
public interface IAgentSession : IDisposable
{
/// <summary>Standard input stream to send messages to the agent.</summary>
StreamWriter StandardInput { get; }
/// <summary>Standard output stream to receive agent output.</summary>
StreamReader StandardOutput { get; }
/// <summary>Standard error stream to receive agent errors.</summary>
StreamReader StandardError { get; }
/// <summary>Wait for the agent to exit.</summary>
Task WaitForExitAsync(CancellationToken cancellationToken = default);
}Process-based agents are the simplest approach. The server spawns an external executable and communicates through standard input/output streams.
Configure the agent in appsettings.json or appsettings.Development.json:
{
"Agent": {
"Command": "/path/to/your/agent",
"Arguments": "--option value",
"LogDirectory": "/var/log/remote-agent",
"RunnerId": "process"
}
}- Command: Full path to the executable or script
- Arguments: Command-line arguments to pass to the agent
- LogDirectory: Directory for session log files (defaults to temp directory)
- RunnerId: Runner implementation to use. When unset or empty: Linux (and other non-Windows) defaults to
"process"(Cursor/agent CLI); Windows defaults to"copilot-windows". Use"process"or"copilot-windows"explicitly to override. - DataDirectory: Directory for LiteDB storage and uploaded media (defaults to ./data)
Use the built-in copilot-windows strategy to run GitHub Copilot CLI on Windows. Install Copilot CLI (e.g. winget install GitHub.Copilot or npm install -g @github/copilot), then set RunnerId to "copilot-windows". If Command is not set, the service uses copilot (from PATH).
{
"Agent": {
"Command": "",
"Arguments": "",
"LogDirectory": "",
"RunnerId": "copilot-windows"
}
}Optionally set Command to the full path of copilot.exe if it is not on PATH.
The simplest test agent echoes input back as output:
{
"Agent": {
"Command": "/bin/cat",
"Arguments": "",
"LogDirectory": "/tmp/agent-logs"
}
}{
"Agent": {
"Command": "/usr/bin/python3",
"Arguments": "/path/to/your/agent.py --mode interactive",
"LogDirectory": "/var/log/remote-agent"
}
}{
"Agent": {
"Command": "/usr/bin/node",
"Arguments": "/path/to/your/agent.js",
"LogDirectory": "/var/log/remote-agent"
}
}Process-based agents must:
- Read from stdin – Accept input messages line-by-line from standard input
- Write to stdout – Send output messages to standard output
- Write to stderr – Send error messages to standard error
- Support graceful termination – Handle SIGTERM and exit cleanly
- Flush output – Ensure output is flushed after each message for real-time streaming
#!/usr/bin/env python3
import sys
import signal
def handle_sigterm(signum, frame):
"""Handle termination signal gracefully."""
sys.stdout.write("Agent shutting down...\n")
sys.stdout.flush()
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
def main():
sys.stdout.write("Agent started and ready\n")
sys.stdout.flush()
# Read commands from stdin
for line in sys.stdin:
command = line.strip()
if not command:
continue
# Process the command
try:
# Your agent logic here
result = f"Processed: {command}"
sys.stdout.write(f"{result}\n")
sys.stdout.flush()
except Exception as e:
sys.stderr.write(f"Error: {str(e)}\n")
sys.stderr.flush()
if __name__ == "__main__":
main()#!/usr/bin/env node
const readline = require('readline');
// Handle graceful termination
process.on('SIGTERM', () => {
console.log('Agent shutting down...');
process.exit(0);
});
// Create readline interface for stdin
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
console.log('Agent started and ready');
// Process each line from stdin
rl.on('line', (line) => {
const command = line.trim();
if (!command) return;
try {
// Your agent logic here
const result = `Processed: ${command}`;
console.log(result);
} catch (error) {
console.error(`Error: ${error.message}`);
}
});
rl.on('close', () => {
console.log('Agent session ended');
process.exit(0);
});Plugin-based agents are .NET assemblies that implement IAgentRunner and optionally IAgentSession. This approach provides full programmatic control over agent behavior.
Create a new .NET class library project:
dotnet new classlib -n MyCustomAgent -f net10.0
cd MyCustomAgent
dotnet add reference /path/to/RemoteAgent.Service/RemoteAgent.Service.csprojusing RemoteAgent.Service.Agents;
using Microsoft.Extensions.Logging;
namespace MyCustomAgent;
public class CustomAgentRunner : IAgentRunner
{
private readonly ILogger<CustomAgentRunner> _logger;
public CustomAgentRunner(ILogger<CustomAgentRunner> logger)
{
_logger = logger;
}
public async Task<IAgentSession?> StartAsync(
string? command,
string? arguments,
string sessionId,
StreamWriter? logWriter,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting custom agent for session {SessionId}", sessionId);
try
{
// Initialize your agent
var session = new CustomAgentSession(sessionId, logWriter, _logger);
await session.InitializeAsync(cancellationToken);
return session;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start custom agent");
return null;
}
}
}using System.IO.Pipelines;
using RemoteAgent.Service.Agents;
using Microsoft.Extensions.Logging;
namespace MyCustomAgent;
public class CustomAgentSession : IAgentSession
{
private readonly string _sessionId;
private readonly StreamWriter? _logWriter;
private readonly ILogger _logger;
private readonly Pipe _inputPipe;
private readonly Pipe _outputPipe;
private readonly Pipe _errorPipe;
private readonly CancellationTokenSource _cts;
private Task? _processingTask;
public CustomAgentSession(string sessionId, StreamWriter? logWriter, ILogger logger)
{
_sessionId = sessionId;
_logWriter = logWriter;
_logger = logger;
_inputPipe = new Pipe();
_outputPipe = new Pipe();
_errorPipe = new Pipe();
_cts = new CancellationTokenSource();
}
public StreamWriter StandardInput => new StreamWriter(_inputPipe.Writer.AsStream());
public StreamReader StandardOutput => new StreamReader(_outputPipe.Reader.AsStream());
public StreamReader StandardError => new StreamReader(_errorPipe.Reader.AsStream());
public async Task InitializeAsync(CancellationToken cancellationToken)
{
// Start background processing
_processingTask = ProcessMessagesAsync(_cts.Token);
await Task.CompletedTask;
}
private async Task ProcessMessagesAsync(CancellationToken cancellationToken)
{
var reader = new StreamReader(_inputPipe.Reader.AsStream());
var writer = new StreamWriter(_outputPipe.Writer.AsStream()) { AutoFlush = true };
try
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken);
if (line == null) break;
// Process the input
var result = await ProcessCommandAsync(line, cancellationToken);
await writer.WriteLineAsync(result);
}
}
catch (OperationCanceledException)
{
// Normal cancellation
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing messages");
}
}
private async Task<string> ProcessCommandAsync(string command, CancellationToken cancellationToken)
{
// Implement your agent logic here
await Task.Delay(10, cancellationToken); // Simulate work
return $"Processed: {command}";
}
public async Task WaitForExitAsync(CancellationToken cancellationToken = default)
{
if (_processingTask != null)
{
await _processingTask;
}
}
public void Dispose()
{
_cts.Cancel();
_processingTask?.Wait(TimeSpan.FromSeconds(5));
_cts.Dispose();
}
}dotnet build -c ReleaseAdd the plugin to appsettings.json:
{
"Agent": {
"RunnerId": "MyCustomAgent.CustomAgentRunner",
"LogDirectory": "/var/log/remote-agent"
},
"Plugins": {
"Assemblies": [
"/path/to/MyCustomAgent.dll"
]
}
}The RunnerId should match the full type name of your runner implementation. The plugin loader will discover and register it by this name.
The server uses PluginLoader to discover and register agent runners:
- Built-in runner: The "process" runner is always available
- Plugin assemblies: Loaded from paths specified in
Plugins:Assemblies - Type discovery: Each assembly is scanned for types implementing
IAgentRunner - Registration: Runners are registered by their full type name (e.g., "MyCustomAgent.CustomAgentRunner")
- Dependency injection: Plugins can use constructor injection to access services
Plugin runners can inject services through their constructor:
public class AdvancedAgentRunner : IAgentRunner
{
private readonly ILogger<AdvancedAgentRunner> _logger;
private readonly IConfiguration _configuration;
private readonly IHttpClientFactory _httpClientFactory;
public AdvancedAgentRunner(
ILogger<AdvancedAgentRunner> logger,
IConfiguration configuration,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_configuration = configuration;
_httpClientFactory = httpClientFactory;
}
// Implementation...
}Agents receive messages from the client through standard input (or the session's input pipe). Each message is typically a line of text terminated by a newline character.
Agents send responses through:
- Standard output – For normal responses (mapped to
ServerMessage.output) - Standard error – For errors and diagnostics (mapped to
ServerMessage.error)
- Client sends
ClientMessagewith text or control commands - Server forwards text messages to the agent's stdin
- Agent processes the message and writes to stdout/stderr
- Server reads agent output and streams it to client as
ServerMessage
-
START: Client sends SessionControl.START
- Server calls
IAgentRunner.StartAsync() - Session begins when
IAgentSessionis returned - Server sends
SessionEvent.SESSION_STARTED
- Server calls
-
ACTIVE: Messages flow bidirectionally
- Client text → Agent stdin
- Agent stdout → Client output
- Agent stderr → Client error
-
STOP: Client sends SessionControl.STOP or disconnects
- Server disposes the
IAgentSession - Agent receives termination signal
- Server sends
SessionEvent.SESSION_STOPPED
- Server disposes the
The server can host multiple agent types simultaneously. Clients can select which agent to use:
{
"Agent": {
"RunnerId": "process"
},
"Plugins": {
"Assemblies": [
"plugins/CursorAgent.dll",
"plugins/CustomAI.dll",
"plugins/CodeAnalyzer.dll"
]
}
}Clients call GetServerInfo() to retrieve the list of available agents and specify the desired agent when starting a session.
Agents can integrate with the server's LiteDB storage:
public class StorageAwareRunner : IAgentRunner
{
private readonly ILocalStorage _localStorage;
public StorageAwareRunner(ILocalStorage localStorage)
{
_localStorage = localStorage;
}
public async Task<IAgentSession?> StartAsync(...)
{
// Access persisted data
var history = _localStorage.GetRecentRequests(sessionId, 10);
// Use history to provide context to the agent
return new MySession(history);
}
}Agents can access uploaded media through the MediaStorageService:
public class MediaAwareRunner : IAgentRunner
{
private readonly MediaStorageService _mediaStorage;
public MediaAwareRunner(MediaStorageService mediaStorage)
{
_mediaStorage = mediaStorage;
}
public async Task<IAgentSession?> StartAsync(...)
{
// Plugin agents can access uploaded media
// Media is stored in Agent:DataDirectory
return new MediaSession(_mediaStorage);
}
}Test your external executable independently:
# Test input/output
echo "test command" | /path/to/your/agent
# Test with file input
/path/to/your/agent < test_input.txt
# Monitor stderr
/path/to/your/agent 2> errors.logCreate xUnit tests for your plugin:
public class CustomAgentRunnerTests
{
[Fact]
public async Task StartAsync_ShouldReturnSession()
{
var logger = new Mock<ILogger<CustomAgentRunner>>().Object;
var runner = new CustomAgentRunner(logger);
var session = await runner.StartAsync(
null, null, "test-session", null, CancellationToken.None);
Assert.NotNull(session);
}
[Fact]
public async Task Session_ShouldProcessInput()
{
// Test agent session behavior
var session = /* create session */;
await session.StandardInput.WriteLineAsync("test input");
var output = await session.StandardOutput.ReadLineAsync();
Assert.Contains("test input", output);
}
}Run the server with your agent and test with a client:
# Start server with your agent
dotnet run --project src/RemoteAgent.Service
# In another terminal, test with grpcurl
grpcurl -plaintext -d @ localhost:5243 proto.AgentGateway/ConnectMount your agent executable into the container:
docker run -p 5243:5243 \
-e Agent__Command=/app/custom-agent \
-e Agent__LogDirectory=/app/logs \
-v /host/path/to/agent:/app/custom-agent:ro \
-v /host/logs:/app/logs \
ghcr.io/sharpninja/remote-agent/service:latestBuild a custom Docker image with your plugin:
FROM ghcr.io/sharpninja/remote-agent/service:latest
# Copy plugin assembly
COPY MyCustomAgent/bin/Release/net10.0/MyCustomAgent.dll /app/plugins/
# Override appsettings
COPY custom-appsettings.json /app/appsettings.Production.jsonBuild and run:
docker build -t custom-remote-agent .
docker run -p 5243:5243 \
-e Agent__RunnerId=MyCustomAgent.CustomAgentRunner \
custom-remote-agentOverride configuration via environment variables:
Agent__Command– Agent executable pathAgent__Arguments– Agent argumentsAgent__LogDirectory– Log directoryAgent__RunnerId– Runner implementationAgent__DataDirectory– Data directoryPlugins__Assemblies__0– First plugin pathPlugins__Assemblies__1– Second plugin path
Example:
export Agent__Command="/usr/local/bin/my-agent"
export Agent__LogDirectory="/var/log/remote-agent"
export Plugins__Assemblies__0="/opt/plugins/CustomAgent.dll"
dotnet run --project src/RemoteAgent.ServiceProblem: Server logs "Agent:Command not configured"
Solution:
- Verify
Agent:Commandis set in appsettings.json - Ensure the path is absolute and the file exists
- Check file permissions (must be executable)
Problem: Agent runs but no output appears in client
Solution:
- Ensure agent flushes stdout after each write
- Check agent is writing to stdout, not stderr
- Verify agent is line-buffered, not block-buffered
- Test agent independently:
echo "test" | /path/to/agent
Problem: Plugin runner not found in registry
Solution:
- Verify assembly path in
Plugins:Assembliesis correct - Check assembly exists and is readable
- Ensure assembly targets net10.0
- Verify type implements
IAgentRunner - Check for missing dependencies in plugin assembly
- Review server logs for plugin loading errors
Problem: Agent process exits unexpectedly or stops responding
Solution:
- Check agent logs in
Agent:LogDirectory - Test agent with manual input
- Add error handling in agent code
- Verify agent handles SIGTERM gracefully
- Check for deadlocks in async code
- Monitor resource usage (memory, CPU)
- Validate input – Always sanitize and validate messages before processing
- Limit permissions – Run agents with minimal required permissions
- Sandbox execution – Consider containerizing agents for isolation
- Avoid shell injection – Don't pass user input directly to shell commands
- Encrypt sensitive data – Use secure storage for credentials and secrets
- Use async I/O – Prefer async operations for all I/O
- Buffer efficiently – Use appropriate buffer sizes for streaming
- Flush promptly – Flush output after each message for low latency
- Manage resources – Dispose streams and processes properly
- Handle backpressure – Implement flow control for high-volume scenarios
- Handle errors gracefully – Catch exceptions and report them via stderr
- Log comprehensively – Use the provided log writer for debugging
- Support cancellation – Respect cancellation tokens
- Implement health checks – Respond to ping/health messages
- Test edge cases – Test with empty input, large messages, special characters
- Follow conventions – Match the coding style of the project
- Document behavior – Add XML comments to public APIs
- Write tests – Create unit and integration tests
- Version APIs – Use semantic versioning for plugin APIs
- Provide examples – Include sample configurations and usage
The repository includes example agents:
- Echo agent (
/bin/cat) – Simple test agent that echoes input - ProcessAgentRunner – Reference implementation for process-based agents
- See
tests/RemoteAgent.Service.Testsfor integration test examples
To implement a new CLI agent:
- Choose approach: Process-based (simplest) or plugin-based (most flexible)
- Implement interface: Follow the
IAgentRunnercontract - Handle I/O: Read from stdin, write to stdout/stderr
- Configure: Update appsettings.json with agent settings
- Test: Verify agent behavior independently and with server
- Deploy: Use Docker or direct deployment as appropriate
For questions or support, see the main README or open an issue on GitHub.
- Functional Requirements: functional-requirements.md – See FR-8.1 for plugin extensibility
- Technical Requirements: technical-requirements.md – See TR-10.1 and TR-10.2 for architecture
- Source Code:
src/RemoteAgent.Service/Agents/IAgentRunner.cs– Core interfacesrc/RemoteAgent.Service/Agents/ProcessAgentRunner.cs– Default implementationsrc/RemoteAgent.Service/PluginLoader.cs– Plugin discovery
- Tests:
tests/RemoteAgent.Service.Tests– Integration test examples