A governed investigative-agent skeleton — the domain-agnostic core delaminated from a production network-diagnostics assistant so it can be re-skinned for any vertical: devops, finance, support triage, compliance, ops.
An agent investigates a target, gathers evidence with read-only tools, and finalizes ranked hypotheses with confidence — under budget/iteration/timeout caps, with write operations gated behind human approval, every run recorded in an HMAC hash-chained append-only audit log, and sensitive identifiers pseudonymized at the LLM boundary. None of that machinery knows anything about your domain; you supply the domain through three small ports.
┌───────────────── agentkit core ─────────────────┐
your vertical → │ loop · budget · audit · profiles · pseudonymizer │
(a DomainPack) │ tool framework · LLM runtime · approval gate │
└───────────────────────────────────────────────────┘
▲ depends on ▲ depends on
│ the three ports │ (never the reverse)
agentkit is a small, opinionated core, not a batteries-included framework. It owns one control flow — gather evidence with tools → finalize ranked hypotheses — under governance (budget/approval/audit/pseudonymization), and nothing else. It deliberately ships no built-in providers, no persistence layer, no agent marketplace, no convergence/execution engine.
Reach for agentkit when you are building an investigative / diagnostic agent (incident triage, SRE/NetOps, compliance checks, support deflection, fraud review) and you need auditability, privacy, and fail-closed governance from day one — especially in regulated or on-prem/sovereign settings. The HMAC hash-chained audit, the pseudonymization-at-the-LLM-boundary with a fail-closed cloud perimeter, and the declarative per-tenant tool policy are the reason to choose it over a general framework.
Reach for something else (LangChain, LlamaIndex, an agent SDK) when you want a broad
toolbox of integrations, RAG/chains/memory out of the box, or a general chat/coding
agent. agentkit is narrower by design; it expects you to bring your domain through the
three ports and to layer persistence/observability as your own concern (hooks are
provided — see RunListener).
What it gives you that general frameworks treat as afterthoughts: governance is
test-locked and fails closed, the public API is a semver contract, and the whole
loop runs without a network (the LLM backend is injectable) so it is testable and
sovereign-deployable. Execution/convergence (DesiredState → apply → rollback) is
intentionally out of scope — that is a separate concern a consumer layers on; the
governance primitives are reusable standalone for exactly that (see govern_tool_call).
A vertical fills these in; the core reads them. Dependency points one way: skin → core.
| Port | What it replaces | What you provide |
|---|---|---|
DomainPack |
the network prompt + triage + vocab | system prompt, core_tool_patterns (kept when narrowing), vocab_stems (non-English relevance), pii_terms(), session_prep() opener, and a resolver |
| ProviderSlot | the hardcoded 5 network slots | register_provider(slot, impl) — backends your tools call |
ResourceResolver |
device name→management-IP resolution |
resolve(target_id, type) — name→address for connects_by_target tools |
Plus @register_tool for the tools themselves (vendor-neutral domain.action names,
metadata — category/weight/risk/write/visibility_gate — declared at the
registration site), and a target: {id, type} that replaces the network device.
python3.12 -m pip install -e ".[dev,llm,api,docs]"
make ci # lint + types + tests + docs + dependency auditBuild a vertical — the step-by-step Writing a skin guide
walks from the reference examples/echo_domain/ (a key/value
"facts catalog" in ~120 lines) to a real one, with the full port contracts. The short
version:
from agentkit import DomainPack, register_tool, register_provider, set_domain, ToolResult
register_provider("store", MyBackend())
@register_tool(name="facts.lookup", description="Read a target's facts.",
provider_slot="store", weight="light", connects_by_target=True)
def lookup(target: str) -> ToolResult:
return ToolResult.ok(get_provider("store").facts[target], source_ref=f"store:{target}")
class MyDomain(DomainPack):
name = "facts"
system_prompt = "You are a catalog investigator. Use facts.lookup, then finalize."
core_tool_patterns = ("facts.lookup",)
set_domain(MyDomain())Run it:
agentkit run node-alpha --type node --domain examples.echo_domain:install
agentkit profile validate # fail-closed check of config/profile.yaml
agentkit audit-verify --db data/audit.db # checksums + chain + seq + tail-truncationOr serve the authenticated reference HTTP adapter (the api extra). Configure
the bearer token and persistent audit path first:
export AGENTKIT_API_BEARER_TOKEN='replace-me'
export AGENTKIT_AUDIT_HMAC_KEY='replace-me-too'
export AGENTKIT_DB_PATH='data/audit.db'
uvicorn agentkit.surfaces.api:app # POST /v1/run · GET /v1/audit/verify · /healthz · /metrics
curl -H "Authorization: Bearer $AGENTKIT_API_BEARER_TOKEN" \
-H "Content-Type: application/json" -d '{"target":"node-alpha","type":"node"}' \
http://127.0.0.1:8000/v1/run/metrics renders Prometheus text exposition (runs/iterations/cost/tool-calls). See
docs/HTTP_API.md before exposing this adapter outside a trusted
development network.
In code:
from agentkit import Session, Target, run_session, AuditLog
result = await run_session(
Session(run_id="r1", target=Target(id="node-alpha", type="node")),
approval_fn=my_approval_callback, # async (payload) -> bool, gates write tools
audit=AuditLog("data/audit.db"),
)The core is a faithful, generalized lift of the upstream product's domain-agnostic
modules — see HARVEST.md for the per-module provenance and exactly what
was generalized. The network skin (providers, prompts, composites, enrichment
topology, vendor backends) was not harvested; it is rebuilt per domain.
Guarantees carried over as framework invariants (locked by tests):
- Frozen models —
model_copy(update=...), never mutate. - Append-only audit — BEFORE UPDATE/DELETE triggers; HMAC hash-chain + monotonic seq.
- Write ops require approval — the gate runs before registry lookup.
- Allow-list defaults to all (empty = inherit), profiles fail closed.
- Pseudonymization — typed bijective reversible tokens;
off | cloud | always. - Core imports no skin —
tests/test_seam.pyenforces the one-way dependency.
The skeleton is secure when configured, and tells you loudly when it is not. Knobs
(all CoreSettings, env-overridable; see config/profile.yaml.example):
| Concern | Setting | Hardening |
|---|---|---|
| Tamper-evident audit | AGENTKIT_AUDIT_HMAC_KEY |
HMAC-SHA256 chain. Absent → a loud warning; checksums fall back to plain SHA-256 (integrity only). |
| Mandatory trail | AGENTKIT_AUDIT_REQUIRED=true |
a failed audit append aborts the run (AuditUnavailableError) instead of being swallowed. |
| Tail-truncation | (automatic) | a sidecar high-water-mark detects dropped/last-rewritten rows that the chain alone cannot. |
| PII to cloud LLMs | AGENTKIT_LLM_PSEUDONYMIZE=cloud |
unknown model/host = cloud; set AGENTKIT_LLM_IS_LOCAL=true only for a trusted on-prem backend. |
| Secret leakage | (automatic) | tool output and audit payloads are redacted (PEM keys, JWTs, URL creds, AWS keys, key=secret). |
| Domain code-exec | AGENTKIT_DOMAIN_ALLOWLIST=examples.,mycorp. |
gates the --domain module:callable import surface to listed prefixes. |
| HTTP authentication | AGENTKIT_API_BEARER_TOKEN |
protected endpoints fail with 503 until configured; insecure mode is explicit and local-only. |
| HTTP tenant scope | AGENTKIT_API_TENANT_ID |
tenant policy is selected server-side; request JSON cannot choose it. |
| HTTP rate limit | AGENTKIT_RATE_LIMIT_PER_MIN |
process-local protection; use a gateway for distributed enforcement. |
All settings are AGENTKIT_-prefixed env vars (collision-safe), or fields on CoreSettings.
| Structured logs | configure_logging() | opt-in JSON/console structlog with run_id/tenant bound on every run. |
The package ships type information (py.typed); the loop's injection seams are typed
via Protocols in agentkit/protocols.py.
agentkit.set_completion_fn(fake) injects the completion backend, so the whole loop
runs deterministically with a scripted fake LLM and no network (litellm is optional).
See tests/conftest.py FakeLLM.
The public API is exactly agentkit.__all__ — what from agentkit import … exposes.
Anything reached by a submodule path (agentkit.tool_adapter.execute_tool, …) is
internal and may change without a major bump. tests/test_public_api.py locks the
surface so it can only move deliberately. Versioning is semantic; removals go through a
@deprecated cycle. Consumers pin a git tag until the package is published to an index:
dependencies = ["agentkit @ git+https://github.com/<org>/agentkit.git@v0.3.0"]See docs/RELEASING.md for the contract, the semver/deprecation
policy, and the release steps (make build, make release-check). A browsable API
reference is generated from docstrings with make docs (the docs extra: MkDocs +
mkdocstrings).
agentkit/
├── agentkit/ # the framework (domain-agnostic)
│ ├── models.py tools.py ports.py config.py # data + the three ports + settings
│ ├── loop.py tool_adapter.py # the agent loop + schema/gate
│ ├── llm.py audit.py profiles.py # LLM runtime · audit chain · profiles
│ ├── pseudonymizer.py redact.py # LLM-boundary privacy
│ ├── protocols.py logging.py metrics.py # typed seams · log config · metrics
│ └── surfaces/cli.py surfaces/api.py # CLI + HTTP (FastAPI) surfaces
├── examples/echo_domain/ # reference skin proving the three ports
├── Dockerfile ARCHITECTURE.md # container image · design doc
├── .github/workflows/ci.yml # ruff + mypy + pytest on 3.12 / 3.13
└── tests/ # pytest (ruff + mypy clean, coverage ≥ 90%)