Skip to content

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

  1. Create a client with a bootstrap token (bt_...), agent_type, and the Zentinelle service endpoint.
  2. Call register() once on startup.
  3. The server returns agent_id, a runtime api_key, initial config, and policies.
  4. The SDK swaps from X-Zentinelle-Bootstrap to X-Zentinelle-Key automatically after registration.
  5. Call evaluate() before guarded actions, emit() after them, and use getConfig() / getSecrets() for cached runtime state.
  6. 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 use X-Zentinelle-Key.
  • In this standalone repo, /secrets and /secrets/{agent_id} currently return an empty bundle unless secret provisioning is implemented externally.
  • Heartbeat currently returns 202 Accepted with {"acknowledged": true}. SDKs are tolerant of future drift/sync fields.
  • /deregister and /interaction were 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/agent
  • plugins/agno
  • plugins/bee-agent
  • plugins/crewai
  • plugins/dspy
  • plugins/google-adk
  • plugins/haystack
  • plugins/langchain
  • plugins/letta
  • plugins/llamaindex
  • plugins/mistral-agents
  • plugins/ms-agent-framework
  • plugins/n8n
  • plugins/openai-agents
  • plugins/pydantic-ai
  • plugins/smolagents
  • plugins/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=True records 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.