The Intent Agent turns a natural-language prompt into an
AuthZEN-style intent: a list of
{subject, action, resource, context} evaluations that an authorization engine can decide on. It
is an A2A-compliant service built for the
IndyKite Agent Gateway: the gateway authenticates
an AI agent, forwards its prompt here with the caller's identity, and sends the extracted intent
on to the IndyKite authorization engine.
What it does:
- Extracts
{prompt, evaluations[]}from the prompt with Gemini, constrained by the vocabulary of your IndyKite Knowledge Graph (node types, relations and policy-granted actions fetched from the platform's data-schema and policy APIs). - Binds the
subjectof every evaluation about the caller to the identity the gateway supplies with the request, so prompt text can never impersonate someone else. The only evaluations that keep a model-chosen subject are a Subject Search ("who can read X?", a type with no id) and the marked second-subject pattern gated by the caller's own first evaluation. - Validates every evaluation against the schema, including any CEL
filtersexpression, and asks the model again when it strays. - Writes one audit record per request, to a file or a webhook.
What it does not do: make authorization decisions. A decision is the downstream engine's job.
For a walkthrough of the Intent Agent in use with the Agent Gateway, see the Intent Agent guide.
Each release publishes the image to Docker Hub as docker.io/indykite/intent-agent, with a Helm chart
under chart/. latest tag is not published. To build locally instead:
cp .env.example .env # fill in the required settings listed under Configuration
docker buildx build --platform linux/amd64 --load -t intent-agent:local .
docker run --platform linux/amd64 --rm -p 6003:6003 -p 8001:8001 --env-file .env \
-e AUDIT_FILE_STORAGE_PATH=/tmp/audit intent-agent:localThe image runs as a non-root user and /app is not writable, so .env.example's relative audit
directory must be overridden to a writable path such as /tmp/audit (as above); otherwise the
container exits at startup. The base images are pinned to multi-arch digests; the command above
forces linux/amd64, so on Apple Silicon it runs under emulation (drop --platform to build a
native arm64 image). The make build_docker_image target builds for linux/amd64 and
linux/arm64 at once with --load, which needs Docker's containerd image store; on the default
image store it fails with "docker exporter does not currently support exporting manifest lists".
Check it is up:
curl http://localhost:8001/health
curl http://localhost:6003/.well-known/agent-card.json/health returns JSON: the agent version, the data schema state (status + version hash) and the
state of each dependency in use - ikg, token_introspection, gemini, the audit sink (audit_webhook
or audit_file, whichever AUDIT_DELIVERY selects) and redis when CACHE_BACKEND=redis (disabled
otherwise). /health makes no network calls itself: every entry is the outcome of the latest real call
this process made to that dependency (schema/policy fetch, introspection, LLM call, audit delivery,
cache access), with checked_at and a source of traffic. At startup the schema fetch records its
outcome (source: startup), and each dependency still without one is probed once (bounded by
HEALTH_CHECK_TIMEOUT_SECONDS), so one that has seen no traffic yet still has a state - the startup
probe also checks the Gemini API key/model (metadata read, no tokens spent) and that the audit
directory is writable. The state is only as fresh as the last call. /health is always HTTP 200 while the process serves - an outage shows as
"status": "degraded" in the body, so liveness/readiness probes don't restart pods over a dependency no
restart can fix.
Send a prompt. This is the A2A JSON-RPC transport, not a REST API: note the A2A-Version header,
the SendMessage method name, the ROLE_USER role and the caller's subject_type/subject_id
in message.metadata. The Bearer token authenticates the calling client (via RFC 7662 token
introspection), not the subject. Every JSON-RPC method needs it, but only SendMessage is served
(GetTask, ListTasks and CancelTask answer "unsupported operation"). The agent card stays public
and advertises the requirement as an HTTP Bearer scheme (securitySchemes / securityRequirements):
curl -X POST http://localhost:6003/ \
-H "Content-Type: application/json" \
-H "A2A-Version: 1.0" \
-H "Authorization: Bearer <token-accepted-by-your-introspection-endpoint>" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "SendMessage",
"params": {
"message": {
"role": "ROLE_USER",
"parts": [{"text": "Can I buy 50 shares of AAPL?"}],
"messageId": "test-msg-1",
"metadata": {
"subject_type": "Customer",
"subject_id": "test-subject"
}
}
}
}'The HTTP response is an A2A JSON-RPC task envelope: result.task.status.state is
TASK_STATE_COMPLETED on success, and result.task.artifacts[0].parts[0].text holds the
extraction as a JSON string, either {prompt, evaluations} or, when nothing coherent can be
extracted, the error shape {prompt, error, reason, obligation}. To run from source, with a mock
introspection endpoint and no Docker, see
CONTRIBUTING.md.
All settings are environment variables (or a .env file), read once at startup by a validated
Settings object. Copy .env.example and fill in the blank values; the service refuses to start
if any of these is missing or blank:
| Setting | Meaning |
|---|---|
GEMINI_API_KEY |
Google Gemini API key used for extraction |
INDYKITE_BASE_URL |
IndyKite platform base URL; the graph schema and policies are fetched from it |
IK_APP_AGENT_KEY |
IndyKite AppAgent credential sent as X-IK-ClientKey |
INTROSPECT_URL |
RFC 7662 token introspection endpoint of the identity provider |
INTROSPECT_CLIENT_ID, INTROSPECT_CLIENT_SECRET |
Credentials for that endpoint |
AUDIT_DELIVERY |
file or webhook; auditing cannot be switched off |
Commonly tuned optional settings:
| Setting | Default | Meaning |
|---|---|---|
INTENT_PORT, HEALTH_PORT |
6003, 8001 |
A2A and health listeners |
ADVERTISED_URL |
http://intent:<INTENT_PORT> |
JSON-RPC URL on the agent card; must match the protocol, hostname and port of the ingress or Istio VirtualService callers use (see below) |
GEMINI_MODEL |
gemini-3.5-flash-lite |
Model used for extraction |
LLM_TIMEOUT_SECONDS, REQUEST_TIMEOUT_SECONDS |
3.0, 10.0 |
Per LLM call, and for the whole request |
HEALTH_CHECK_TIMEOUT_SECONDS |
3.0 |
Bound on each startup probe that seeds /health |
VALIDATION_MAX_RETRIES |
1 |
Extra attempts after an off-schema answer (0 disables) |
TOOL_CALL_MAX_ITERATIONS |
5 |
Tool-call rounds before giving up |
SCHEMA_REFRESH_TTL_MINUTES, POLICY_REFRESH_TTL_MINUTES |
5.0 |
How long a cached schema / policy list is served |
CACHE_BACKEND, REDIS_URL |
memory, - |
redis shares the schema cache between instances; REDIS_URL is then required |
EXPECTED_TOKEN_AUDIENCE |
intent_agent |
Required aud of the caller's token. Published in the public agent card, so a client can request the right token |
LOG_LEVEL |
INFO |
Python log level (.env.example sets DEBUG for local runs) |
ADVERTISED_URL is published on the agent card and never checked or derived from the deployment, so
it has to be kept in step with it by hand: its protocol, hostname and port must be the ones callers
actually use to reach the agent - the Kubernetes Ingress (ingress.hosts, ingress.tls) or the Istio
VirtualService (istio.virtualService.hosts, gateways) in the Helm chart, or whatever gateway fronts
it elsewhere. For example https://intent-agent.example.com for a TLS ingress on that host. It does not
follow INTENT_PORT, which is only the container port behind the ingress. Clients that discover the
agent through its card call this URL, so a mismatch (http:// in front of a TLS ingress, a port the
ingress doesn't serve, an internal hostname) makes the agent unreachable for them. The agent logs a
warning at startup when it is unset. In the chart it is app.advertisedUrl.
Every request produces one audit record with the fields timestamp, request_id,
subject_id, subject_type, requesting_agent_id, context_id, prompt, evaluations,
schema_version, policy_version, llm_provider, llm_model, latency_ms,
validation_retries, policy_fetch_failed, outcome (success / validation_failed /
unmatched / error) and message. The delivery options are
the same as the
Agent Gateway's:
| Env var | Meaning |
|---|---|
AUDIT_DELIVERY |
Required. file or webhook |
AUDIT_ASYNC |
true delivers through a bounded in-memory queue drained by a background worker, flushed on shutdown (default false, inline delivery) |
AUDIT_ASYNC_BUFFER_SIZE |
Queue length; a full queue drops records with the warning dropping payload, async queue is full (default 512) |
AUDIT_FILE_STORAGE_PATH |
Directory for audit-<name>-<unixNano>.<format> files, created if missing |
AUDIT_FILE_FORMAT |
csv, json (one object per line) or txt |
AUDIT_FILE_ROTATION_STRATEGY |
time, size or size-and-time |
AUDIT_FILE_ROTATION_INTERVAL_SECONDS |
Default 86400 (24h) |
AUDIT_FILE_ROTATION_MAX_BYTES |
Default 104857600 (100 MiB) |
AUDIT_HTTP_URL |
Webhook receiving one request per record, the record as JSON body |
AUDIT_HTTP_METHOD |
POST or PUT |
AUDIT_HTTP_AUTH_TYPE |
mTLS, api-key or no-auth |
AUDIT_HTTP_MTLS_CERTIFICATE_FILE_PATH |
Client certificate (mTLS, TLS 1.3 minimum) |
AUDIT_HTTP_MTLS_PRIVATE_KEY_PATH |
Client key (mTLS) |
AUDIT_HTTP_API_KEY |
Key sent in the header below (api-key) |
AUDIT_HTTP_API_KEY_HEADER |
Default X-API-Key |
AUDIT_HTTP_TIMEOUT_SECONDS |
Per-delivery timeout (default 5.0) |
Auditing cannot be switched off: AUDIT_DELIVERY has no default and the process fails at startup
without it. The chosen delivery has required settings of its own, also checked at startup:
file needs AUDIT_FILE_STORAGE_PATH, AUDIT_FILE_FORMAT and AUDIT_FILE_ROTATION_STRATEGY;
webhook needs AUDIT_HTTP_URL, AUDIT_HTTP_METHOD and AUDIT_HTTP_AUTH_TYPE, plus both mTLS
file paths for mTLS or AUDIT_HTTP_API_KEY for api-key. A failed delivery (unreachable
webhook, disk error) is logged and never fails the request being audited.
Releases are cut with release-please from Conventional Commits on master; the container image
and the Helm chart are published to Docker Hub on each release. IndyKite's own dev and rc
environments are updated automatically from master pushes and releases respectively; neither
is needed to build, run or contribute to the agent.
CONTRIBUTING.md: prerequisites, make targets, running from source with a mock introspection endpoint, Docker and the multi-instance docker-compose stack, the PR workflow.docs/testing.md: the golden eval suite that measures the real model against fixture prompts, and hands-on walkthroughs for the audit log, an end-to-end run against a real IndyKite environment, and Subject Search.docs/maintainers.md: for maintainers - triage, reviewing and evaluating pull requests from forks, merging, and what to check before a release.
Contributions are welcome. CONTRIBUTING.md describes the workflow (fork,
branch off master, Conventional Commits, make fix then make build_integration), the ground rules for
changes to the pipeline, and which shared configuration files are kept identical across IndyKite
repos and should not be changed in a feature PR. Everyone participating is expected to follow the
Code of Conduct.
Before starting on a non-trivial change, open an issue and wait until a maintainer labels it
accepted; ideas and questions go to Discussions.
Please do not report security issues in public issues or PRs. See SECURITY.md
and the full responsible disclosure policy for the private
reporting channel, scope and response times.
Licensed under the Apache License, Version 2.0; the LICENSE file at the repo root
applies to every file in the tree.