Go microservice that runs autonomous AI agents with tool-calling capabilities.
Bridges Laravel (or any HTTP client) with LLM providers (OpenAI, Gemini, Anthropic)
via a turn-based agent loop, streaming results in real-time over SSE.
Laravel SDK: ginkida/laravel-agent-runner — first-class PHP integration with fluent builder API, auto-discovery of remote tools, SSE stream consumption, and HMAC signing handled for you.
- Features
- Laravel SDK
- Quick Start
- How It Works
- Architecture
- API Reference
- Built-in Tools
- Remote Tools
- HMAC Authentication
- SSE Streaming
- Configuration Reference
- Deployment
- Security
- Troubleshooting
- License
- Multi-provider — OpenAI, Google Gemini, Anthropic Claude. Switch models by changing one string.
- Built-in tools —
bash,read_file,write_file,edit_file,glob,grep,list_dir— sandboxed to a working directory. - Remote tools — Define custom tools in your app; Agent Runner calls back via HMAC-signed HTTP.
- Real-time SSE streaming — Text, tool calls, and results streamed as they happen.
- Autonomous agent loop — The agent decides which tools to call, processes results, and continues until the task is complete.
- Loop detection — Automatically detects when an agent is stuck repeating the same action and intervenes.
- Production-ready — Circuit breakers, rate limiting, HMAC auth, SSRF protection, graceful shutdown.
- Minimal dependencies — Go stdlib + chi router + YAML parser. No SDKs, no ORMs, no frameworks.
- Official Laravel SDK —
ginkida/laravel-agent-runnerhandles all the HTTP/HMAC/SSE complexity so you can focus on your agent logic.
The recommended way to integrate with Agent Runner from Laravel is the official SDK: ginkida/laravel-agent-runner.
composer require ginkida/laravel-agent-runner
php artisan vendor:publish --tag=agent-runner-configAdd to your .env:
AGENT_RUNNER_URL=http://localhost:8090
AGENT_RUNNER_HMAC_SECRET=your-shared-secret # must match Agent Runner's auth.hmac_secret
AGENT_RUNNER_CLIENT_ID=laravel
AGENT_RUNNER_CALLBACK_URL=https://your-app.com/api/agent-runner
AGENT_RUNNER_DEFAULT_MODEL=gpt-4o-miniThree execution modes — pick what fits your use case:
run() — synchronous, blocking. Creates a session, sends a message, consumes the entire SSE stream, and returns the final result. Best for simple request-response flows.
$result = AgentRunner::agent('assistant')
->model('gpt-4o')
->systemPrompt('You are a helpful assistant.')
->tools(['read_file', 'write_file', 'bash'])
->remoteTools(['search_database'])
->onText(fn(string $text) => echo $text)
->run('Summarize the README.md file');
$result->doneOutput(); // "The README describes..."
$result->doneTurns(); // 3
$result->doneDurationMs(); // 8420start() — manual stream control. Returns a session ID and an SseStream for fine-grained event consumption. Best when you need to process events individually.
$session = AgentRunner::agent('coder')
->systemPrompt('You are a senior developer.')
->withAllRemoteTools()
->start('Refactor the User model');
foreach ($session['stream']->events() as $event) {
match($event->type) {
'text' => $this->handleText($event->textContent()),
'tool_call' => $this->handleToolCall($event->toolName(), $event->toolArgs()),
'tool_result' => $this->handleToolResult($event->toolName(), $event->data['success']),
'error' => $this->handleError($event->errorMessage()),
'done' => break,
};
}dispatch() — fire-and-forget. Creates the session and sends a message, returns immediately with the session ID. Results arrive asynchronously via status callback events.
$sessionId = AgentRunner::agent('worker')
->dispatch('Process the uploaded CSV file');Implement RemoteToolContract and place in app/AgentTools/ — the SDK auto-discovers them:
namespace App\AgentTools;
use Ginkida\AgentRunner\Contracts\RemoteToolContract;
use Ginkida\AgentRunner\DTOs\ToolCallbackRequest;
class SearchDatabase implements RemoteToolContract
{
public function name(): string { return 'search_database'; }
public function description(): string { return 'Search the application database.'; }
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string', 'description' => 'Search query'],
],
'required' => ['query'],
];
}
public function handle(ToolCallbackRequest $request): array
{
$results = DB::table('records')
->where('content', 'like', "%{$request->argument('query')}%")
->limit($request->argument('limit', 10))
->get();
return ['success' => true, 'content' => $results->toJson()];
}
}The SDK dispatches Laravel events on status callbacks — listen for AgentSessionCompleted, AgentSessionFailed, etc:
// EventServiceProvider or listener
use Ginkida\AgentRunner\Events\AgentSessionCompleted;
class HandleAgentCompletion
{
public function handle(AgentSessionCompleted $event): void
{
$output = $event->payload->output;
// process the agent's result
}
}For full SDK documentation, see the Laravel Agent Runner SDK repo.
docker run -d \
-p 8090:8090 \
-e AGENT_RUNNER_AUTH_HMAC_SECRET=your-secret \
-e AGENT_RUNNER_PROVIDERS_OPENAI_KEY=sk-... \
-e AGENT_RUNNER_CALLBACK_BASE_URL=https://your-app.com/api/agent-runner \
ghcr.io/ginkida/agent-runner:latestcp config.example.yaml config.yaml
# Edit config.yaml with your secrets
docker-compose up# Build
go build -o agent-runner ./cmd/agent-runner/
# Run
./agent-runner --config config.yaml
# Or with env vars (no config file needed)
AGENT_RUNNER_AUTH_HMAC_SECRET=secret \
AGENT_RUNNER_PROVIDERS_OPENAI_KEY=sk-... \
./agent-runnercurl http://localhost:8090/health
# {"status":"ok","active_sessions":0,"total_sessions":0}Laravel Agent Runner LLM Provider
│ │ │
├─ POST /v1/sessions ──────────────>│ Create session │
│ <── 201 {session_id} │ │
│ │ │
├─ POST /v1/sessions/{id}/messages ─>│ │
│ <── 202 Accepted │ │
│ ├─ Send message ───────────────────>│
│ │<─ Stream response ────────────────┤
│ <── SSE: text chunks │ │
│ │ (agent decides to use tools) │
│ <── SSE: tool_call │ │
│ ├─ Execute built-in tool │
│ <── SSE: tool_result │ │
│ │ (agent calls remote tool) │
│ <── SSE: tool_call │ │
│ <── POST /tools/{name} │ (HMAC-signed callback) │
│ ──> {success, content} │ │
│ <── SSE: tool_result │ │
│ ├─ Send results ───────────────────>│
│ │<─ Stream response ────────────────┤
│ <── SSE: text chunks │ ... repeat until done ... │
│ <── SSE: done │ │
│ │ │
│ <── POST /sessions/{id}/status │ (status callback) │
│ │ │
- Create session —
POST /v1/sessionsdefines the agent: name, model, system prompt, available tools. Returns asession_id. - Send message —
POST /v1/sessions/{id}/messageswith a task for the agent. Returns202 Acceptedimmediately; the agent runs in a background goroutine. - Stream results —
GET /v1/sessions/{id}/streamopens an SSE connection. You receive events in real-time as the agent thinks and acts. - Status callbacks — Agent Runner sends HTTP callbacks to your Laravel app when the session transitions states (
created→running→completed/failed/cancelled). - Cleanup — Sessions are automatically reaped after the configured TTL (default 30 minutes).
Each turn of the agent loop:
- Send the conversation history to the LLM
- Stream the response — text is forwarded to SSE in real-time as chunks arrive
- If the LLM returns tool calls, execute them in parallel (up to 5 concurrent, 2-minute timeout per tool)
- Append tool results to conversation history
- Repeat from step 1
The loop stops when:
- The LLM responds with text only (no tool calls) — task complete
- Maximum turns reached (default 30)
- Context timeout expires (default 5 minutes)
- Session is cancelled
If the agent calls the same tool with the same arguments 3 times, Agent Runner injects an intervention message:
LOOP DETECTED: Tool 'read_file' called 3 times with same arguments. Try a different approach.
This prevents infinite loops where the agent gets stuck. The counter resets after intervention, giving the agent a chance to try something different.
The model name prefix determines which provider is used:
| Prefix | Provider | Example models |
|---|---|---|
gpt-, o1-, o3-, chatgpt- |
OpenAI | gpt-4o, gpt-4o-mini, o3-mini |
gemini- |
Google Gemini | gemini-2.0-flash, gemini-1.5-pro |
claude- |
Anthropic | claude-sonnet-4-20250514, claude-haiku-4-5-20251001 |
No SDKs are used — all providers are called via raw HTTP with streaming response parsing.
created ──> running ──> completed
├──> failed
└──> cancelled (via DELETE)
created ──> (never started, reaped after TTL)
Each provider prefix (gpt, gemini, claude) has a shared circuit breaker:
- Closed (normal) — requests flow through; failures are counted
- Open (failing) — requests are immediately rejected with
ErrCircuitOpenafter reachingmax_failures(default 5) - Half-Open (probing) — after
reset_timeout_sec(default 30s), one request is allowed through as a probe. If it succeeds, the circuit closes; if it fails, it re-opens.
All /v1/* endpoints require HMAC authentication and an X-Client-ID header.
GET /health
No authentication required. Used for liveness probes.
Response:
{
"status": "ok",
"active_sessions": 2,
"total_sessions": 5
}POST /v1/sessions
Creates a new agent session. The session is in created state until a message is sent.
Request body:
{
"session_id": "my-custom-id",
"work_dir": "/path/to/project",
"callback": {
"base_url": "https://my-app.com/api/agent-runner",
"timeout_sec": 15
},
"agent": {
"name": "code-reviewer",
"model": "gpt-4o",
"system_prompt": "You are a senior code reviewer. Be thorough and specific.",
"max_turns": 10,
"max_tokens": 4096,
"temperature": 0.7,
"tools": {
"builtin": ["read_file", "write_file", "bash", "glob", "grep"],
"remote": [
{
"name": "search_docs",
"description": "Search internal documentation",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
}
]
}
}
}| Field | Required | Description |
|---|---|---|
session_id |
No | Custom session ID (1-128 alphanumeric/dash/underscore). Auto-generated if omitted. |
work_dir |
No | Working directory for built-in tools. Must be an absolute path. Defaults to server CWD. |
callback.base_url |
No | Override the global callback URL for this session. |
callback.timeout_sec |
No | Override the callback timeout for this session. |
agent.name |
Yes | Agent name (for logging and identification). |
agent.model |
No | LLM model to use. Defaults to defaults.model from config. |
agent.system_prompt |
No | System instruction sent to the LLM. |
agent.max_turns |
No | Maximum agent loop iterations. Defaults to defaults.max_turns. |
agent.max_tokens |
No | Maximum output tokens per LLM call. Defaults to defaults.max_tokens. |
agent.temperature |
No | LLM temperature (0.0–2.0). |
agent.tools.builtin |
No | List of built-in tool names to enable. |
agent.tools.remote |
No | List of remote tool definitions with JSON Schema parameters. |
Response (201):
{
"session_id": "a1b2c3d4e5f6",
"status": "created"
}Errors:
| Status | Reason |
|---|---|
| 400 | Invalid request body, invalid session_id format, invalid work_dir, invalid callback URL |
| 409 | Session ID already exists |
GET /v1/sessions/{id}
Returns the current state of a session.
Response (200):
{
"session_id": "a1b2c3d4e5f6",
"name": "code-reviewer",
"model": "gpt-4o",
"status": "completed",
"output": "I found 3 issues in the codebase...",
"turns": 5,
"duration_ms": 12340,
"created_at": "2026-02-26T12:00:00Z"
}| Field | Description |
|---|---|
status |
created, running, completed, failed, or cancelled |
output |
Final text output from the agent (only on completed) |
error |
Error message (only on failed) |
turns |
Number of agent loop iterations |
duration_ms |
Total execution time in milliseconds |
DELETE /v1/sessions/{id}
Cancels a running session and removes it. If the agent is mid-execution, its context is cancelled.
Response (200):
{
"status": "deleted"
}POST /v1/sessions/{id}/messages
Starts the agent loop with the given message. Returns immediately with 202 Accepted — the agent runs asynchronously in a background goroutine.
Request body:
{
"message": "Analyze the code in /src and find potential security vulnerabilities"
}Response (202):
{
"session_id": "a1b2c3d4e5f6",
"status": "running",
"tools_registered": ["read_file", "glob", "grep", "search_docs"]
}Errors:
| Status | Reason |
|---|---|
| 400 | Invalid request body or empty message |
| 404 | Session not found or not owned by client |
| 409 | Session is already running |
| 429 | Maximum concurrent running sessions reached |
GET /v1/sessions/{id}/stream
Opens a Server-Sent Events (SSE) connection. Events are streamed in real-time as the agent executes. This endpoint has no timeout — the connection stays open until the agent finishes (then the server closes it) or the client disconnects.
See SSE Streaming for event format details.
All built-in tools are sandboxed to the session's working directory. Symlinks are resolved to prevent directory traversal. Sensitive paths are blocked (.ssh, .aws, .kube, .docker/config.json, /etc/shadow, /etc/sudoers, etc.).
Read the contents of a file.
| Parameter | Type | Required | Description |
|---|---|---|---|
file_path |
string | Yes | Path to the file (relative to work_dir or absolute within work_dir) |
offset |
integer | No | 1-based line number to start reading from |
limit |
integer | No | Number of lines to read |
- Maximum file size: 10MB
- Output format: numbered lines (
1\tpackage main)
Create or overwrite a file.
| Parameter | Type | Required | Description |
|---|---|---|---|
file_path |
string | Yes | Path to the file |
content |
string | Yes | File content to write |
- Creates parent directories automatically (mode 0755)
- Files written with mode 0644
Make targeted edits to an existing file.
| Parameter | Type | Required | Description |
|---|---|---|---|
file_path |
string | Yes | Path to the file |
old_string |
string | Yes | Exact text to find and replace |
new_string |
string | Yes | Replacement text |
replace_all |
boolean | No | Replace all occurrences (default: false) |
- By default, replaces only the first occurrence
- Errors if
old_stringis not found or appears more than once (unlessreplace_allis true)
Execute a shell command.
| Parameter | Type | Required | Description |
|---|---|---|---|
command |
string | Yes | Shell command to execute |
timeout |
integer | No | Timeout in seconds (default: 120) |
Resource limits (via ulimit):
| Limit | Value | Purpose |
|---|---|---|
Max processes (-u) |
64 | Fork bomb protection |
Max file size (-f) |
10MB | Prevents filling disk |
Max virtual memory (-v) |
512MB | Prevents OOM |
Output limits:
- stdout and stderr are each capped at 100KB
- Truncated output includes a
... (output truncated)notice
Blocked commands:
- Destructive:
rm -rf /,mkfs.*,dd,shutdown,reboot,halt - Fork bombs:
:(){ :|:& };: - Network to stdin:
curl|bash,wget|sh,ssh,nc - Inline code execution:
python -c,ruby -e,/dev/tcp - Sensitive file access:
chmod 777 /, raw device writes - Credential paths:
/.ssh/,/.aws/,/.config/gcloud/
Environment: Commands run in an isolated environment with minimal PATH, TERM=dumb, and a per-session temp directory at /tmp/agent-runner/{session_id}.
Find files by pattern.
| Parameter | Type | Required | Description |
|---|---|---|---|
pattern |
string | Yes | Glob pattern (supports ** for recursive matching) |
path |
string | No | Directory to search in (default: work_dir) |
- Supports doublestar patterns:
**/*.go,src/**/*.ts - Automatically skips:
.git,node_modules,vendor,.idea - Maximum 1000 matches returned
- Results are newline-separated absolute paths
Search file contents by regex.
| Parameter | Type | Required | Description |
|---|---|---|---|
pattern |
string | Yes | Regular expression pattern (Go regex syntax) |
path |
string | No | File or directory to search (default: work_dir) |
include |
string | No | Glob filter for file names (e.g., *.go) |
- Full Go regex syntax
- Skips directories:
.git,node_modules,vendor,.idea,.vscode,__pycache__ - Skips binary files (>1MB or containing null bytes)
- Maximum 100 matching lines
- Output format:
filepath:linenum:content
List directory contents.
| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string | No | Directory to list (default: work_dir) |
- Output format:
name[/]\tsize(directories have/suffix)
Remote tools let you extend the agent with your application's business logic. When the agent calls a remote tool, Agent Runner sends an HMAC-signed HTTP request to your Laravel app.
- Define the tool in the session creation request with a name, description, and JSON Schema parameters
- The LLM sees the tool definition and can decide to call it
- Agent Runner sends a POST to
{callback_base_url}/tools/{tool_name} - Your app processes the request and returns a result
- The result is passed back to the LLM for the next turn
POST {callback_base_url}/tools/{tool_name}
Content-Type: application/json
X-Session-ID: abc123
X-Signature: sha256=<hex_digest>
X-Timestamp: 1709000000
X-Nonce: 4f9a4f1f6d4d7a8f87b5a5c8c2f12a10
{
"session_id": "abc123",
"tool_name": "search_docs",
"arguments": {
"query": "authentication middleware"
}
}Success:
{
"success": true,
"content": "Found 3 documents about authentication middleware..."
}Error:
{
"success": false,
"error": "No documents found for this query"
}- 3 retries with exponential backoff (1s → 2s → 4s, max 10s, ±20% jitter)
- 5xx responses — retried automatically
- 4xx responses — not retried, returned as tool error immediately
- Network errors (timeout, connection refused, connection reset, EOF) — retried
If you're using Laravel, the Laravel Agent Runner SDK handles routing, HMAC verification, and tool dispatch automatically. Just implement RemoteToolContract and place it in app/AgentTools/ — see the Laravel SDK section above.
For other frameworks, implement the callback endpoint manually:
// routes/api.php
Route::post('agent-runner/tools/{tool}', [AgentToolController::class, 'handle'])
->middleware('verify-agent-hmac');
// app/Http/Controllers/AgentToolController.php
class AgentToolController extends Controller
{
public function handle(Request $request, string $tool)
{
return match ($tool) {
'search_docs' => $this->searchDocs($request->input('arguments')),
'create_ticket' => $this->createTicket($request->input('arguments')),
default => response()->json(['success' => false, 'error' => 'Unknown tool'], 404),
};
}
private function searchDocs(array $args): JsonResponse
{
$results = Document::search($args['query'])->take(5)->get();
return response()->json([
'success' => true,
'content' => $results->map->summary->implode("\n\n"),
]);
}
}Agent Runner also sends status updates to your app when session state changes:
POST {callback_base_url}/sessions/{session_id}/status
{
"session_id": "abc123",
"client_id": "my-app",
"status": "completed",
"output": "Analysis complete. Found 3 issues...",
"turns": 5,
"duration_ms": 12340
}Status transitions: created → running → completed / failed / cancelled
These callbacks are fire-and-forget — they never block the agent. If the callback fails, it's retried up to 3 times, then dropped.
All /v1/* endpoints are authenticated via HMAC-SHA256 signatures. The /health endpoint is unauthenticated.
Every request must include three headers:
| Header | Value |
|---|---|
X-Signature |
sha256=<hex_hmac_digest> |
X-Timestamp |
Unix timestamp (seconds) |
X-Nonce |
Unique random string per request |
The signature is computed as:
payload = "{timestamp}.{nonce}.{request_body}"
signature = "sha256=" + hex(HMAC-SHA256(payload, secret))
For GET/DELETE requests with no body, use an empty string as the request body.
- Timestamps must be within ±2 minutes of the server's clock
X-Noncemust be unique for each request within the freshness window
function signRequest(string $secret, string $body): array
{
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$payload = "{$timestamp}.{$nonce}.{$body}";
$signature = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return [
'X-Signature' => $signature,
'X-Timestamp' => $timestamp,
'X-Nonce' => $nonce,
];
}const crypto = require('crypto');
function signRequest(secret, body) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString('hex');
const payload = `${timestamp}.${nonce}.${body}`;
const signature = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
return {
'X-Signature': signature,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
};
}If auth.hmac_secret is empty, authentication is skipped entirely. This is only recommended for local development.
Connect to GET /v1/sessions/{id}/stream to receive real-time events. The connection uses standard Server-Sent Events.
Streamed text from the LLM. You'll receive many of these events as the model generates its response token by token.
event: text
data: {"content":"Let me analyze "}
event: text
data: {"content":"the code structure..."}
The agent has decided to call a tool. Sent before tool execution.
event: tool_call
data: {"tool":"read_file","args":{"file_path":"src/main.go"}}
A tool has finished executing.
event: tool_result
data: {"tool":"read_file","success":true,"content":"package main\n\nimport..."}
An error occurred during agent execution.
event: error
data: {"message":"context deadline exceeded"}
The agent has finished. This is always the last event.
event: done
data: {"status":"completed","output":"Found 3 issues...","turns":5,"duration_ms":12340}
status is either completed or failed.
A heartbeat comment is sent every 30 seconds to keep the connection alive:
: heartbeat
const eventSource = new EventSource(
`http://localhost:8090/v1/sessions/${sessionId}/stream`,
{ headers: { 'X-Client-ID': 'my-app', ...signRequest(secret, '') } }
);
eventSource.addEventListener('text', (e) => {
const { content } = JSON.parse(e.data);
process.stdout.write(content);
});
eventSource.addEventListener('tool_call', (e) => {
const { tool, args } = JSON.parse(e.data);
console.log(`\n[Calling ${tool}]`);
});
eventSource.addEventListener('done', (e) => {
const { status, turns, duration_ms } = JSON.parse(e.data);
console.log(`\nDone: ${status} in ${turns} turns (${duration_ms}ms)`);
eventSource.close();
});
eventSource.addEventListener('error', (e) => {
const { message } = JSON.parse(e.data);
console.error('Agent error:', message);
eventSource.close();
});Configuration is loaded in this order (later overrides earlier):
- Built-in defaults
- YAML config file — path from
--configflag, or./config.yaml, or~/.config/agent-runner/config.yaml - Environment variables —
AGENT_RUNNER_prefix
server:
host: "0.0.0.0" # Listen address
port: 8090 # Listen port (1–65535)
max_body_bytes: 10485760 # Max request body size (10MB)
read_header_timeout_sec: 5 # Protect against slowloris headers
read_timeout_sec: 30 # Max time to read full request
write_timeout_sec: 65 # Max time to write non-stream responses
idle_timeout_sec: 120 # Keep-alive idle timeout
tls:
cert_file: "" # TLS certificate path (both or neither)
key_file: "" # TLS private key path
auth:
hmac_secret: "" # HMAC-SHA256 shared secret (required)
providers:
openai_key: "" # OpenAI API key
gemini_key: "" # Google Gemini API key
anthropic_key: "" # Anthropic API key
defaults:
model: "gpt-4o-mini" # Default model when agent doesn't specify
max_turns: 30 # Default max agent loop turns
max_tokens: 4096 # Default max output tokens per LLM call
timeout_secs: 300 # Default agent timeout in seconds
sessions:
max_concurrent: 50 # Max simultaneous running sessions (0 = unlimited)
ttl_minutes: 30 # Minutes before completed sessions are reaped
callback:
base_url: "http://localhost:8000/api/agent-runner" # Laravel callback URL
timeout_sec: 30 # Callback request timeout
rate_limit:
requests_per_second: 20 # Per-client rate limit
burst: 40 # Token bucket burst size
circuit_breaker:
max_failures: 5 # Failures before circuit opens
reset_timeout_sec: 30 # Seconds before half-open probe
security:
allow_private_networks: false # Allow callbacks to private/internal IPs (Docker, Swarm, K8s)Every config value can be overridden via environment variables with the AGENT_RUNNER_ prefix:
| Variable | Config path | Example |
|---|---|---|
AGENT_RUNNER_SERVER_HOST |
server.host |
0.0.0.0 |
AGENT_RUNNER_SERVER_PORT |
server.port |
8090 |
AGENT_RUNNER_SERVER_MAX_BODY_BYTES |
server.max_body_bytes |
10485760 |
AGENT_RUNNER_SERVER_READ_HEADER_TIMEOUT_SEC |
server.read_header_timeout_sec |
5 |
AGENT_RUNNER_SERVER_READ_TIMEOUT_SEC |
server.read_timeout_sec |
30 |
AGENT_RUNNER_SERVER_WRITE_TIMEOUT_SEC |
server.write_timeout_sec |
65 |
AGENT_RUNNER_SERVER_IDLE_TIMEOUT_SEC |
server.idle_timeout_sec |
120 |
AGENT_RUNNER_TLS_CERT_FILE |
server.tls.cert_file |
/etc/ssl/cert.pem |
AGENT_RUNNER_TLS_KEY_FILE |
server.tls.key_file |
/etc/ssl/key.pem |
AGENT_RUNNER_AUTH_HMAC_SECRET |
auth.hmac_secret |
my-secret |
AGENT_RUNNER_PROVIDERS_OPENAI_KEY |
providers.openai_key |
sk-... |
AGENT_RUNNER_PROVIDERS_GEMINI_KEY |
providers.gemini_key |
AI... |
AGENT_RUNNER_PROVIDERS_ANTHROPIC_KEY |
providers.anthropic_key |
sk-ant-... |
AGENT_RUNNER_DEFAULTS_MODEL |
defaults.model |
gpt-4o |
AGENT_RUNNER_DEFAULTS_MAX_TURNS |
defaults.max_turns |
30 |
AGENT_RUNNER_DEFAULTS_MAX_TOKENS |
defaults.max_tokens |
4096 |
AGENT_RUNNER_DEFAULTS_TIMEOUT_SECS |
defaults.timeout_secs |
300 |
AGENT_RUNNER_SESSIONS_MAX_CONCURRENT |
sessions.max_concurrent |
50 |
AGENT_RUNNER_SESSIONS_TTL_MINUTES |
sessions.ttl_minutes |
30 |
AGENT_RUNNER_CALLBACK_BASE_URL |
callback.base_url |
http://app:8000/api/agent-runner |
AGENT_RUNNER_CALLBACK_TIMEOUT_SEC |
callback.timeout_sec |
30 |
AGENT_RUNNER_RATELIMIT_RPS |
rate_limit.requests_per_second |
20 |
AGENT_RUNNER_RATELIMIT_BURST |
rate_limit.burst |
40 |
AGENT_RUNNER_CB_MAX_FAILURES |
circuit_breaker.max_failures |
5 |
AGENT_RUNNER_CB_RESET_TIMEOUT_SEC |
circuit_breaker.reset_timeout_sec |
30 |
AGENT_RUNNER_SECURITY_ALLOW_PRIVATE_NETWORKS |
security.allow_private_networks |
false |
All sensitive variables support a _FILE suffix for Docker Swarm secrets:
AGENT_RUNNER_AUTH_HMAC_SECRET_FILE=/run/secrets/hmac_secret
AGENT_RUNNER_PROVIDERS_OPENAI_KEY_FILE=/run/secrets/openai_keyThe file content is read and trimmed of whitespace.
On startup, the config is validated:
auth.hmac_secretis required- At least one provider key must be set
server.portmust be 1–65535defaults.timeout_secsmust be positivecallback.timeout_secmust not be negativeserver.read_header_timeout_sec,server.read_timeout_sec,server.write_timeout_sec,server.idle_timeout_secmust not be negativesessions.max_concurrentmust not be negative (0 = unlimited)sessions.ttl_minutesmust be positive- TLS: both
cert_fileandkey_filemust be set (or neither), and both files must exist
cp config.example.yaml config.yaml
# Edit config.yaml with your API keys and HMAC secret
docker-compose up -dThe docker-compose.yml includes:
- Read-only root filesystem
no-new-privilegessecurity option- tmpfs for temp files (256MB)
- Resource limits (512MB RAM, 1 CPU)
- Log rotation (10MB × 3 files)
host.docker.internalmapping for local Laravel access
# Create secrets
echo "your-hmac-secret" | docker secret create hmac_secret -
echo "sk-..." | docker secret create openai_key -
echo "" | docker secret create gemini_key -
echo "" | docker secret create anthropic_key -
# Deploy
docker stack deploy -c docker-stack.yml agent-runnerIf your Laravel app runs in the same Swarm and the callback URL uses the overlay network (e.g., http://laravel-app:8000/api/agent-runner), enable private network access so SSRF protection doesn't block internal traffic:
AGENT_RUNNER_SECURITY_ALLOW_PRIVATE_NETWORKS=trueThe docker-stack.yml includes:
- 1 replica (sessions are in-memory)
- Automatic rollback on failure
- Resource limits and reservations
- Health checks
- Overlay network
For multi-replica deployments, you must add sticky routing and an external shared session store; otherwise messages/get/stream requests can hit different replicas and return session not found.
Download pre-built binaries from Releases:
| Platform | Architecture | File |
|---|---|---|
| Linux | x86_64 | agent-runner-linux-amd64 |
| Linux | ARM64 | agent-runner-linux-arm64 |
| macOS | x86_64 | agent-runner-darwin-amd64 |
| macOS | Apple Silicon | agent-runner-darwin-arm64 |
chmod +x agent-runner-linux-amd64
./agent-runner-linux-amd64 --config config.yamlUse the Docker image with your own manifests or Helm chart:
apiVersion: apps/v1
kind: Deployment
metadata:
name: agent-runner
spec:
replicas: 1
selector:
matchLabels:
app: agent-runner
template:
metadata:
labels:
app: agent-runner
spec:
containers:
- name: agent-runner
image: ghcr.io/ginkida/agent-runner:latest
ports:
- containerPort: 8090
env:
- name: AGENT_RUNNER_AUTH_HMAC_SECRET
valueFrom:
secretKeyRef:
name: agent-runner-secrets
key: hmac-secret
- name: AGENT_RUNNER_PROVIDERS_OPENAI_KEY
valueFrom:
secretKeyRef:
name: agent-runner-secrets
key: openai-key
- name: AGENT_RUNNER_CALLBACK_BASE_URL
value: "http://laravel-app:8000/api/agent-runner"
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 3
periodSeconds: 10
resources:
limits:
memory: 512Mi
cpu: "1"
requests:
memory: 128Mi
cpu: 250m
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
allowPrivilegeEscalation: false
volumeMounts:
- name: workspace
mountPath: /workspace
- name: tmp
mountPath: /tmp/agent-runner
volumes:
- name: workspace
emptyDir: {}
- name: tmp
emptyDir:
sizeLimit: 256MiTo enable TLS, set both certificate and key paths:
server:
tls:
cert_file: "/etc/ssl/certs/agent-runner.pem"
key_file: "/etc/ssl/private/agent-runner-key.pem"Or via environment:
AGENT_RUNNER_TLS_CERT_FILE=/etc/ssl/certs/cert.pem
AGENT_RUNNER_TLS_KEY_FILE=/etc/ssl/private/key.pemOn SIGINT/SIGTERM, Agent Runner:
- Stops accepting new connections
- Cancels all running agents and waits for them to finish
- Drains SSE connections
- Shuts down the HTTP server
- Cleans up background goroutines
The shutdown timeout is defaults.timeout_secs + 10 seconds (minimum 10 seconds), giving running agents time to complete before force-stopping.
- HMAC-SHA256 on all API endpoints (except
/health) - Timestamp freshness check (±2 minutes)
- Per-request nonce (
X-Nonce) blocks replay within the freshness window - Constant-time signature comparison prevents timing attacks
- SSRF protection —
SafeTransportresolves DNS at dial time and blocks connections to private IP ranges (10.x, 172.16.x, 192.168.x, 127.x, link-local, loopback). Can be disabled withsecurity.allow_private_networks: truefor Docker/Swarm/Kubernetes internal network deployments. - Callback URL validation — blocks
localhost, private IPs, and URLs longer than 2000 chars (private IP checks are skipped whenallow_private_networksis enabled) - Rate limiting — per-client token bucket with configurable RPS and burst
- Built-in tools are sandboxed to the working directory
- Symlinks are resolved to prevent directory traversal
- Sensitive paths are blocked:
/.ssh/,/.aws/,/.config/gcloud/,/.kube/,/.docker/config.json,/etc/shadow,/etc/sudoers - Bash commands run with strict ulimit (64 procs, 10MB files, 512MB memory)
- Dangerous commands are blocked by regex (rm -rf, mkfs, fork bombs, curl|bash, etc.)
- Runs as unprivileged user (
agent) - Read-only root filesystem
no-new-privilegesflag- tmpfs for ephemeral temp files
Set the HMAC secret via config or environment:
export AGENT_RUNNER_AUTH_HMAC_SECRET=your-secretSet at least one LLM provider API key:
export AGENT_RUNNER_PROVIDERS_OPENAI_KEY=sk-...Too many sessions are running simultaneously. Either:
- Wait for running sessions to complete
- Increase
sessions.max_concurrent - Delete idle sessions via
DELETE /v1/sessions/{id}
The LLM provider has failed too many times. The circuit breaker will automatically attempt a probe after reset_timeout_sec (default 30s). Check:
- Provider API key is valid
- Provider service is reachable
- Rate limits haven't been exceeded on the provider side
The SSE connection closes when the client disconnects after receiving the done event. If your client isn't closing the connection, handle the done event:
eventSource.addEventListener('done', () => eventSource.close());Agent Runner blocks SSRF attempts by default. If your callback URL legitimately resolves to a private IP (e.g., Docker Swarm overlay network, Kubernetes cluster DNS), enable private network access:
security:
allow_private_networks: trueOr via environment variable:
AGENT_RUNNER_SECURITY_ALLOW_PRIVATE_NETWORKS=trueAlternatively, in Docker Compose on a single host, use host.docker.internal to reach the host machine without disabling SSRF protection.
Tool outputs are capped at 100KB. If you need larger outputs, consider:
- Using
offsetandlimitparameters forread_file - Filtering output in bash commands with
head,tail, orgrep - Splitting work across multiple tool calls
