Build agents that extend Sentinel's security and policy capabilities.
Inspect, block, redirect, and transform HTTP traffic.
The Sentinel Agent Elixir SDK provides a simple, behaviour-based API for building agents that integrate with the Sentinel reverse proxy. Agents can inspect requests and responses, block malicious traffic, add headers, and attach audit metadata—all from Elixir.
Add sentinel_agent_sdk to your dependencies in mix.exs:
def deps do
[
{:sentinel_agent_sdk, github: "raskell-io/sentinel-agent-elixir-sdk"}
]
endCreate my_agent.ex:
defmodule MyAgent do
use SentinelAgentSdk.Agent
alias SentinelAgentSdk.{Decision, Request}
@impl true
def name, do: "my-agent"
@impl true
def on_request(request) do
if Request.path_starts_with?(request, "/admin") do
Decision.deny() |> Decision.with_body("Access denied")
else
Decision.allow()
end
end
end
# Run the agent
SentinelAgentSdk.run(MyAgent, socket: "/tmp/my-agent.sock")Run the agent:
mix run --no-halt -e 'SentinelAgentSdk.run(MyAgent, socket: "/tmp/my-agent.sock")'| Feature | Description |
|---|---|
| Simple Agent API | Implement on_request, on_response, and other hooks via behaviours |
| Fluent Decision Builder | Pipe operators: Decision.deny() |> Decision.with_body(...) |> Decision.with_tag(...) |
| Request/Response Wrappers | Ergonomic access to headers, body, query params, metadata |
| Typed Configuration | ConfigurableAgent behaviour with struct-based config support |
| OTP Native | Built on OTP for reliable, concurrent processing |
| Protocol Compatible | Full compatibility with Sentinel agent protocol v1 |
Sentinel's agent system moves complex logic out of the proxy core and into isolated, testable, independently deployable processes:
- Security isolation — WAF engines, auth validation, and custom logic run in separate processes
- Language flexibility — Write agents in Elixir, Python, Rust, Go, or any language
- Independent deployment — Update agent logic without restarting the proxy
- Failure boundaries — Agent crashes don't take down the dataplane
Agents communicate with Sentinel over Unix sockets using a simple length-prefixed JSON protocol.
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │────────▶│ Sentinel │────────▶│ Upstream │
└─────────────┘ └──────────────┘ └──────────────┘
│
│ Unix Socket (JSON)
▼
┌──────────────┐
│ Agent │
│ (Elixir) │
└──────────────┘
- Client sends request to Sentinel
- Sentinel forwards request headers to agent
- Agent returns decision (allow, block, redirect) with optional header mutations
- Sentinel applies the decision
- Agent can also inspect response headers before they reach the client
The Agent behaviour defines the hooks you can implement:
defmodule MyAgent do
use SentinelAgentSdk.Agent
alias SentinelAgentSdk.{Decision, Request, Response}
@impl true
def name, do: "my-agent"
@impl true
def on_request(request) do
# Called when request headers arrive
Decision.allow()
end
@impl true
def on_request_body(request) do
# Called when request body is available (if body inspection enabled)
Decision.allow()
end
@impl true
def on_response(request, response) do
# Called when response headers arrive from upstream
Decision.allow()
end
@impl true
def on_response_body(request, response) do
# Called when response body is available (if body inspection enabled)
Decision.allow()
end
@impl true
def on_request_complete(request, status, duration_ms) do
# Called when request processing completes. Use for logging/metrics.
:ok
end
endAccess HTTP request data with convenience functions:
def on_request(request) do
alias SentinelAgentSdk.Request
# Path matching
if Request.path_starts_with?(request, "/api/"), do: # ...
if Request.path_equals?(request, "/health"), do: Decision.allow()
# Headers (case-insensitive)
auth = Request.header(request, "authorization")
unless Request.has_header?(request, "x-api-key") do
Decision.unauthorized()
end
# Common headers as functions
host = Request.host(request)
user_agent = Request.user_agent(request)
content_type = Request.content_type(request)
# Query parameters
page = Request.query(request, "page") || "1"
# Request metadata
client_ip = Request.client_ip(request)
correlation_id = Request.correlation_id(request)
# Body (when body inspection is enabled)
if Request.body(request) != <<>> do
data = Request.body_str(request)
end
Decision.allow()
endInspect upstream responses before they reach the client:
def on_response(request, response) do
alias SentinelAgentSdk.Response
# Status code
if Response.status_code(response) >= 500 do
Decision.allow() |> Decision.with_tag("upstream-error")
end
# Headers
content_type = Response.header(response, "content-type")
# Add security headers to all responses
Decision.allow()
|> Decision.add_response_header("X-Frame-Options", "DENY")
|> Decision.add_response_header("X-Content-Type-Options", "nosniff")
|> Decision.remove_response_header("Server")
endBuild responses with a fluent API using the pipe operator:
alias SentinelAgentSdk.Decision
# Allow the request
Decision.allow()
# Block with common status codes
Decision.deny() # 403 Forbidden
Decision.unauthorized() # 401 Unauthorized
Decision.rate_limited() # 429 Too Many Requests
Decision.block(503) # Custom status
# Block with response body
Decision.deny() |> Decision.with_body("Access denied")
Decision.block(400) |> Decision.with_json_body(%{"error" => "Invalid request"})
# Redirect
Decision.redirect("/login") # 302 temporary
Decision.redirect("/new-path", 301) # 301 permanent
Decision.redirect_permanent("/new-path") # 301 permanent
# Modify headers
Decision.allow()
|> Decision.add_request_header("X-User-ID", user_id)
|> Decision.remove_request_header("Cookie")
|> Decision.add_response_header("X-Cache", "HIT")
|> Decision.remove_response_header("X-Powered-By")
# Audit metadata (appears in Sentinel logs)
Decision.deny()
|> Decision.with_tag("blocked")
|> Decision.with_rule_id("SQLI-001")
|> Decision.with_confidence(0.95)
|> Decision.with_metadata("matched_pattern", pattern)For agents with typed configuration:
defmodule RateLimitConfig do
defstruct requests_per_minute: 60, enabled: true
end
defmodule RateLimitAgent do
use SentinelAgentSdk.ConfigurableAgent
alias SentinelAgentSdk.{Decision, Request}
@impl true
def name, do: "rate-limiter"
@impl true
def default_config, do: %RateLimitConfig{}
@impl true
def parse_config(config_map) do
%RateLimitConfig{
requests_per_minute: Map.get(config_map, "requests_per_minute", 60),
enabled: Map.get(config_map, "enabled", true)
}
end
@impl true
def on_config_applied(config) do
IO.puts("Rate limit set to #{config.requests_per_minute}/min")
:ok
end
@impl true
def on_request(request, config) do
if not config.enabled do
Decision.allow()
else
# Use config.requests_per_minute...
Decision.allow()
end
end
end# Simple usage
SentinelAgentSdk.run(MyAgent, socket: "/tmp/my-agent.sock")
# With options
SentinelAgentSdk.run(MyAgent,
socket: "/tmp/my-agent.sock",
log_level: :debug,
json_logs: true
)| Option | Description | Default |
|---|---|---|
:socket |
Unix socket path | /tmp/sentinel-agent.sock |
:log_level |
:debug, :info, :warning, :error |
:info |
:json_logs |
Output logs as JSON | false |
# Run example agent
elixir examples/simple_agent.exs
# With custom socket
elixir -e 'SentinelAgentSdk.run(MyAgent, socket: "/tmp/custom.sock")'Configure Sentinel to connect to your agent:
agents {
agent "my-agent" type="custom" {
unix-socket path="/tmp/my-agent.sock"
events "request_headers"
timeout-ms 100
failure-mode "open"
}
}
filters {
filter "my-filter" {
type "agent"
agent "my-agent"
}
}
routes {
route "api" {
matches {
path-prefix "/api/"
}
upstream "backend"
filters "my-filter"
}
}| Option | Description | Default |
|---|---|---|
unix-socket path="..." |
Path to agent's Unix socket | required |
events |
Events to send: request_headers, request_body, response_headers, response_body |
request_headers |
timeout-ms |
Timeout for agent calls | 1000 |
failure-mode |
"open" (allow on failure) or "closed" (block on failure) |
"open" |
See docs/configuration.md for complete configuration reference.
The examples/ directory contains complete, runnable examples:
| Example | Description |
|---|---|
simple_agent.exs |
Basic request blocking and header modification |
configurable_agent.exs |
Rate limiting with typed configuration |
body_inspection_agent.exs |
Request and response body inspection |
See docs/examples.md for more patterns: authentication, rate limiting, IP filtering, header transformation, and more.
This project uses mise for tool management.
# Install tools
mise install
# Install dependencies
mix deps.get
# Run tests
mix test
# Run tests with coverage
mix test --cover
# Type checking
mix dialyzer
# Lint
mix format --check-formatted
# Format code
mix format# Ensure Elixir 1.17+ and Erlang 27+ are installed
mix deps.get
mix testsentinel-agent-elixir-sdk/
├── lib/sentinel_agent_sdk/
│ ├── agent.ex # Agent and ConfigurableAgent behaviours
│ ├── decision.ex # Decision builder
│ ├── protocol.ex # Wire protocol types and encoding
│ ├── request.ex # Request wrapper
│ ├── response.ex # Response wrapper
│ └── runner.ex # Runner and socket handling
├── test/
│ ├── sentinel_agent_sdk_test.exs # Unit tests
│ ├── protocol_conformance_test.exs # Protocol compatibility tests
│ └── integration/ # Integration tests
├── examples/ # Example agents
└── docs/ # Documentation
This SDK implements Sentinel Agent Protocol v1:
- Transport: Unix domain sockets (UDS) or gRPC
- Encoding: Length-prefixed JSON (4-byte big-endian length prefix) for UDS
- Max message size: 10 MB
- Events:
configure,request_headers,request_body_chunk,response_headers,response_body_chunk,request_complete,websocket_frame,guardrail_inspect - Decisions:
allow,block,redirect,challenge
The protocol is designed for low latency and high throughput, with support for streaming body inspection.
For the canonical protocol specification, see the Sentinel Agent Protocol documentation.
- Issues — Bug reports and feature requests
- Sentinel Discussions — Questions and ideas
- Sentinel Documentation — Proxy documentation
Contributions welcome. Please open an issue to discuss significant changes before submitting a PR.
Apache 2.0 — See LICENSE.