SDK Guide¶
The Zentinelle SDKs wrap the agent-facing REST API at /api/zentinelle/v1. All maintained clients now follow the same bootstrap-to-runtime auth flow and the same core endpoint set.
SDK source: github.com/calliopeai/zentinelle-sdk
Supported SDKs¶
| Language | Source Directory | Notes |
|---|---|---|
| Python | python/zentinelle |
Synchronous client with background flush and heartbeat threads |
| TypeScript / Node | typescript/src |
Async client for Node and compatible runtimes |
| Go | go/zentinelle |
context.Context-based API |
| Java | java/src/main/java/ai/zentinelle |
Builder-style client |
| C# | csharp/src/Zentinelle |
Async-first client |
Shared Flow¶
- Create a client with a bootstrap token (
bt_...),agent_type, and the Zentinelle service endpoint. - Call
register()once on startup. - The server returns
agent_id, a runtimeapi_key, initialconfig, andpolicies. - The SDK swaps from
X-Zentinelle-BootstraptoX-Zentinelle-Keyautomatically after registration. - Call
evaluate()before guarded actions,emit()after them, and usegetConfig()/getSecrets()for cached runtime state. - Let background heartbeat and flush loops run, or call the explicit flush/shutdown methods before exit.
Shared Runtime Contract¶
All maintained SDKs target these endpoints:
| Method | Path | Used For |
|---|---|---|
POST |
/register |
Bootstrap registration |
POST |
/deregister |
Clean unregister on shutdown |
GET |
/config/{agent_id} |
Runtime config and effective policies |
GET |
/secrets/{agent_id} |
Scoped secrets |
POST |
/evaluate |
Guardrail and policy checks |
POST |
/events |
Buffered telemetry, audit, and alert events |
POST |
/heartbeat |
Health and liveness reporting |
POST |
/interaction |
Direct interaction logging (when bypassing proxy) |
Notes:
- Registration uses
X-Zentinelle-Bootstrap; runtime requests useX-Zentinelle-Key. - In this standalone repo,
/secretsand/secrets/{agent_id}currently return an empty bundle unless secret provisioning is implemented externally. - Heartbeat currently returns
202 Acceptedwith{"acknowledged": true}. SDKs are tolerant of future drift/sync fields. /deregisterand/interactionwere added in v1.2.0 (Python, TypeScript, Go).
Python¶
from zentinelle import ZentinelleClient
client = ZentinelleClient(
api_key="bt_<tenant_id>_<signature>",
agent_type="codex",
endpoint="http://localhost:8080",
)
registration = client.register(
capabilities=["chat", "tool:search"],
metadata={"version": "1.0.0"},
name="codex-dev-agent",
)
decision = client.evaluate(
"tool_call",
user_id="user_123",
context={"tool": "web_search"},
)
if not decision.allowed:
raise PermissionError(decision.reason or "blocked by policy")
client.emit(
"tool_call",
{"tool": "web_search", "duration_ms": 1420},
category="audit",
user_id="user_123",
)
# Log a direct LLM interaction (when bypassing the proxy)
client.log_interaction(
prompt="What's the weather in Paris?",
response="It's currently 18°C and partly cloudy.",
model="gpt-4o",
provider="openai",
input_tokens=20,
output_tokens=15,
cost_usd=0.0024,
)
client.flush_events()
client.deregister() # cleanly unregister on shutdown
client.shutdown()
TypeScript / Node¶
import { ZentinelleClient } from 'zentinelle';
const client = new ZentinelleClient({
apiKey: 'bt_<tenant_id>_<signature>',
agentType: 'codex',
endpoint: 'http://localhost:8080',
});
const registration = await client.register({
capabilities: ['chat', 'tool:search'],
metadata: { version: '1.0.0' },
name: 'codex-dev-agent',
});
const decision = await client.evaluate('tool_call', {
userId: 'user_123',
context: { tool: 'web_search' },
});
if (!decision.allowed) {
throw new Error(decision.reason ?? 'blocked by policy');
}
client.emit(
'tool_call',
{ tool: 'web_search', duration_ms: 1420 },
{ category: 'audit', userId: 'user_123' }
);
await client.flushEvents();
await client.shutdown();
Go¶
client, err := zentinelle.NewClient(zentinelle.Config{
APIKey: "bt_<tenant_id>_<signature>",
AgentType: "codex",
Endpoint: "http://localhost:8080",
})
if err != nil {
log.Fatal(err)
}
defer client.Shutdown()
registration, err := client.Register(ctx, zentinelle.RegisterOptions{
Capabilities: []string{"chat", "tool:search"},
Metadata: map[string]interface{}{"version": "1.0.0"},
Name: "codex-dev-agent",
})
if err != nil {
log.Fatal(err)
}
decision, err := client.Evaluate(ctx, "tool_call", zentinelle.EvaluateOptions{
UserID: "user_123",
Context: map[string]interface{}{"tool": "web_search"},
})
if err != nil {
log.Fatal(err)
}
if !decision.Allowed {
log.Fatalf("blocked: %s", decision.Reason)
}
client.Emit("tool_call", map[string]interface{}{
"tool": "web_search",
"duration_ms": 1420,
}, zentinelle.EmitOptions{
Category: "audit",
UserID: "user_123",
})
if err := client.FlushEvents(ctx); err != nil {
log.Fatal(err)
}
_ = registration
Java¶
ZentinelleClient client = ZentinelleClient.builder()
.apiKey("bt_<tenant_id>_<signature>")
.agentType("codex")
.endpoint("http://localhost:8080")
.build();
RegisterResult registration = client.register(RegisterOptions.builder()
.capabilities(List.of("chat", "tool:search"))
.metadata(Map.of("version", "1.0.0"))
.name("codex-dev-agent")
.build());
EvaluateResult decision = client.evaluate("tool_call", EvaluateOptions.builder()
.userId("user_123")
.context(Map.of("tool", "web_search"))
.build());
if (!decision.isAllowed()) {
throw new IllegalStateException(decision.getReason());
}
client.emit(
"tool_call",
Map.of("tool", "web_search", "duration_ms", 1420),
EmitOptions.builder().category(EventCategory.AUDIT).userId("user_123").build()
);
client.flushEvents();
client.shutdown();
C¶
var client = new ZentinelleClient(new ZentinelleOptions
{
ApiKey = "bt_<tenant_id>_<signature>",
AgentType = "codex",
BaseUrl = "http://localhost:8080",
});
var registration = await client.RegisterAsync(new RegisterOptions
{
Capabilities = new List<string> { "chat", "tool:search" },
Metadata = new Dictionary<string, object> { ["version"] = "1.0.0" },
Name = "codex-dev-agent",
});
var decision = await client.EvaluateAsync("tool_call", new EvaluateOptions
{
UserId = "user_123",
Context = new Dictionary<string, object> { ["tool"] = "web_search" },
});
if (!decision.Allowed)
{
throw new InvalidOperationException(decision.Reason ?? "blocked by policy");
}
client.EmitToolCall("web_search", "user_123", 1420);
await client.FlushAsync();
await client.DisposeAsync();
Plugins¶
The SDK repo also carries framework adapters and runtime integrations under plugins/, including:
plugins/agentplugins/agnoplugins/bee-agentplugins/crewaiplugins/dspyplugins/google-adkplugins/haystackplugins/langchainplugins/lettaplugins/llamaindexplugins/mistral-agentsplugins/ms-agent-frameworkplugins/n8nplugins/openai-agentsplugins/pydantic-aiplugins/smolagentsplugins/vercel-ai
Spring AI is a Java integration and lives in the Java SDK rather than under
plugins/: ai.zentinelle.springai.ZentinelleAdvisor.
Use the plugin-specific READMEs in the SDK repo when you want framework-native instrumentation rather than calling the core client directly.
OpenAI Agents SDK¶
plugins/openai-agents, agent_type: openai_agents.
The Agents SDK has no single interception point, so the plugin uses four, and a governed deployment wants all of them:
| Piece | Covers | Enforcing |
|---|---|---|
configure() |
every LLM call, including ones the SDK makes on its own account | yes, at the gateway |
zentinelle_input_guardrail |
run input | yes, before the model is called |
zentinelle_output_guardrail |
final output | yes, before the caller sees it |
ZentinelleRunHooks |
tool calls, handoffs, tokens | yes, a denied tool does not run |
ZentinelleTracingProcessor |
the run trace | records only |
from agents import Agent, Runner
from zentinelle import ZentinelleClient
from zentinelle_openai_agents import (
ZentinelleRunHooks, configure,
zentinelle_input_guardrail, zentinelle_output_guardrail,
)
client = ZentinelleClient(api_key="sk_agent_...", agent_type="openai_agents")
configure(gateway_url="https://zentinelle-gateway.internal", zentinelle_client=client)
agent = Agent(
name="assistant",
instructions="Help the user.",
input_guardrails=[zentinelle_input_guardrail(client)],
output_guardrails=[zentinelle_output_guardrail(client)],
)
result = await Runner.run(agent, "...", hooks=ZentinelleRunHooks(client))
Three defaults differ from the stock SDK, deliberately:
- Traces are not sent to OpenAI. The stock exporter uploads prompts, tool
arguments and outputs to OpenAI's trace store, which defeats the point of
running Zentinelle to keep that content inside your boundary. Opt back in with
send_traces_to_openai=True. - Span contents are not recorded. The audit trail carries the shape of a
run, not a second copy of the text;
include_span_data=Truerecords the text. - The input guardrail runs before the agent, not in parallel with it. The SDK default is right for scoring and wrong for enforcement, since a parallel denial arrives after the model call has been made.
This SDK calls the OpenAI Responses API by default, which names its token
counts input_tokens / output_tokens. Gateways older than the fix in
gateway/usage.go read only the Chat Completions names and metered these runs
as zero, so cost policies and usage limits saw nothing. Run a current gateway.
Tier 2 harnesses¶
Five more, added together. What matters when picking an integration is not the framework's popularity but how much of a run it can actually stop, and that varies more than the frameworks' marketing does.
| Plugin | agent_type |
Requests | Tool calls | How |
|---|---|---|---|---|
pydantic-ai |
pydantic_ai |
enforced | enforced | capability hooks, awaited on the path to the call |
agno |
agno |
enforced | enforced | pre_hooks / post_hooks / tool_hooks |
google-adk |
google_adk |
enforced | enforced | an ADK plugin, covering every agent in the runner |
haystack |
haystack |
enforced | enforced (Agent only) | a wrapper component, plus a before_tool hook |
smolagents |
smolagents |
enforced | enforced | wrapping the model and each tool; no hooks exist |
letta |
letta |
enforced | not possible | the call site; the agent loop runs server-side |
Three of these differ from what the framework's own docs would lead you to expect, and the difference is the deployment decision:
Agno. The hooks must raise Agno's own InputCheckError / OutputCheckError.
Agno catches and logs every other exception a hook raises, so a plugin raising
anything else would look like it was enforcing while the run carried on, including
when the control plane is unreachable and the check itself failed. The plugin
raises the framework exceptions for exactly this reason.
smolagents. There are no pre-execution hooks at all. step_callbacks fires
after the model has answered and the tool has run, and final_answer_checks
gates only the final answer. Enforcement is therefore by wrapping, and both the
model wrapper and the tool wrappers are needed: a CodeAgent does not call
tools through the agent, it writes Python that the executor runs with the tool
objects in scope, so nothing sits between the model and a tool except the tool.
Letta. The client is a REST wrapper around a server that runs the agent
loop, so nothing client-side can veto a tool call or see a memory edit as it
happens. What the plugin enforces is whether a message is sent at all, which is
real; what it records is token usage and, by diffing around a call, memory
block changes. Letta's own requires_approval flag is the only thing that
stops a Letta tool call, and require_tool_approval() sets it, but that is
Letta gating the tool rather than Zentinelle deciding. Anyone planning a Letta
deployment should read plugins/letta/README.md before assuming otherwise.
Google ADK denials are returned rather than raised. That is ADK's contract:
a before_* callback returning non-None short-circuits what follows and the
returned value becomes the result. The plugin registers at the ADK plugin
layer rather than as per-agent callbacks, so it covers sub-agents created at
runtime and cannot be pre-empted by an agent's own callback.
All six default to failing closed, and all take fail_open=True to prefer
availability.
Tier 3 harnesses¶
| Plugin | agent_type |
Requests | Tool calls | How |
|---|---|---|---|---|
dspy |
dspy |
enforced | enforced | subclassed LM, wrapped tool func |
bee-agent |
bee_agent |
enforced | enforced | emitter start listeners that raise |
mistral-agents |
mistral_agents |
enforced | not possible | the call site; the agent loop runs server-side |
| Spring AI (Java) | spring_ai |
enforced | n/a | a CallAdvisor that decides whether to call the chain |
Qwen and DeepSeek get no agent_type and no plugin, on purpose. Both are
OpenAI-compatible and reach the existing /proxy/openai/ route unchanged, as
described under OpenAI-Compatible Providers below; a separate value would
name a distinction the gateway does not make.
Two of these do not work the way they look:
DSPy. BaseCallback has on_lm_start and on_tool_start, and neither can
block. DSPy's dispatcher catches whatever a callback raises, logs it, and then
calls the wrapped function anyway, so a governance callback would print a
warning and let the request through. Enforcement is therefore a subclassed
dspy.LM plus a wrapper around each tool's func. ZentinelleCallback exists
for audit and is audit only.
Mistral Agents. Server-side runtime: the agent loop and every server-side
tool (web search, code interpreter, connectors) run on Mistral's
infrastructure. There is no tool-call hook type in the SDK at all. What is
enforceable is whether a request is sent, which GovernedMistral does at the
call site. install_request_hook() covers every request through a client but
reaches into a private attribute of the SDK and is documented as unsupported;
it fails loudly if the internals move rather than leaving a deployment
believing it is governed.
BeeAI is the opposite case, and the only event system across all three
tiers that genuinely refuses: a listener's exception is re-raised rather than
logged, and "start" fires before the work.
Spring AI is Java. ChatClient.builder(model).defaultAdvisors(new
ZentinelleAdvisor(client)) — the advisor decides whether to call
chain.nextCall(request), so a denial means the model is never reached. Only
the blocking CallAdvisor half is implemented; streaming calls go through
StreamAdvisor, which is a different design rather than the same one wrapped
in a publisher, and should be routed through the gateway instead.
OpenAI-Compatible Providers (Qwen, DeepSeek, and others)¶
Providers that expose an OpenAI-compatible API (Qwen, DeepSeek, Mistral, Together AI, Groq, etc.) require no plugin and no new agent type. Point the client's base URL at Zentinelle's existing OpenAI proxy endpoint.
Python (openai SDK):
from openai import OpenAI
client = OpenAI(
api_key="<your-provider-key>",
base_url="http://localhost:8080/proxy/openai/v1",
default_headers={"X-Zentinelle-Key": "sk_agent_..."},
)
TypeScript:
import OpenAI from "openai";
const client = new OpenAI({
apiKey: "<your-provider-key>",
baseURL: "http://localhost:8080/proxy/openai/v1",
defaultHeaders: { "X-Zentinelle-Key": "sk_agent_..." },
});
Environment variables (works with any OpenAI-compatible SDK that respects these):
OPENAI_BASE_URL=http://localhost:8080/proxy/openai/v1
OPENAI_API_KEY=<your-provider-key>
# Inject the Zentinelle key via a custom header in your SDK init, or set it in zentinelle.yaml
Once routed through the proxy, all standard policy evaluation (rate limits, content filtering, model restrictions, output filters) applies. Interaction logs and token usage are recorded against the agent's agent_id regardless of which upstream provider is used.
Supported via this path: Qwen (Alibaba), DeepSeek, Mistral, Together AI, Groq, Fireworks, Anyscale, Perplexity, and any other provider with an OpenAI-compatible /v1/chat/completions endpoint.
Built-In Resilience¶
Across languages, the SDKs provide the same operational baseline:
- Retry with backoff for transient HTTP failures
- Circuit-breaker support plus configurable fail-open behavior
- Buffered event emission with periodic flush
- Cached config and secrets reads
- Periodic heartbeats once registration succeeds
The exact option names vary by language, but the behavior is intentionally aligned around the same server contract.