Implementing a New CLI Agent with the Server
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).
Overview
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.
Architecture
Core Interfaces
IAgentRunner
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);
}
IAgentSession
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);
}
Implementation Option 1: Process-Based Agent
Process-based agents are the simplest approach. The server spawns an external executable and communicates through standard input/output streams.
Configuration
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"
}
}
Configuration Options
- 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)
Example: GitHub Copilot CLI on Windows
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.
Example: Simple Echo Agent
The simplest test agent echoes input back as output:
{
"Agent": {
"Command": "/bin/cat",
"Arguments": "",
"LogDirectory": "/tmp/agent-logs"
}
}
Example: Python Agent
{
"Agent": {
"Command": "/usr/bin/python3",
"Arguments": "/path/to/your/agent.py --mode interactive",
"LogDirectory": "/var/log/remote-agent"
}
}
Example: Node.js Agent
{
"Agent": {
"Command": "/usr/bin/node",
"Arguments": "/path/to/your/agent.js",
"LogDirectory": "/var/log/remote-agent"
}
}
Agent Requirements
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
Example Agent Template (Python)
#!/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()
Example Agent Template (Node.js)
#!/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);
});
Implementation Option 2: Plugin-Based Agent
Plugin-based agents are .NET assemblies that implement IAgentRunner and optionally IAgentSession. This approach provides full programmatic control over agent behavior.
Step 1: Create a Plugin Project
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.csproj
Step 2: Implement IAgentRunner
using 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;
}
}
}
Step 3: Implement IAgentSession
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();
}
}
Step 4: Build the Plugin
dotnet build -c Release
Step 5: Configure the Server to Load the Plugin
Add 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.
Plugin Discovery and Registration
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 Dependencies
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...
}
Agent Communication Protocol
Input Messages
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.
Output Messages
Agents send responses through:
- Standard output – For normal responses (mapped to
ServerMessage.output) - Standard error – For errors and diagnostics (mapped to
ServerMessage.error)
Message Flow
- 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
Session Lifecycle
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
Advanced Features
Multiple Agent Support
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.
Local Storage Integration
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);
}
}
Media Handling
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);
}
}
Testing Your Agent
Unit Testing Process-Based Agents
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.log
Unit Testing Plugin Agents
Create 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);
}
}
Integration Testing
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/Connect
Deployment
Docker Deployment with Custom Agent
Process-Based Agent
Mount 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:latest
Plugin-Based Agent
Build 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.json
Build and run:
docker build -t custom-remote-agent .
docker run -p 5243:5243 \
-e Agent__RunnerId=MyCustomAgent.CustomAgentRunner \
custom-remote-agent
Environment Variables
Override 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.Service
Troubleshooting
Agent Not Starting
Problem: 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)
No Output from Agent
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
Plugin Not Loading
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
Agent Crashes or Hangs
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)
Best Practices
Security
- 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
Performance
- 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
Reliability
- 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
Maintainability
- 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
Examples and Templates
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
Summary
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.
References
- 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