Back to all guides
Agent Gateway

Agent Gateway: Authorize Agent-to-Agent and MCP Calls

How the IndyKite Agent Gateway (IAG) decides whether an agent call may proceed, what to set up in your identity provider and your IndyKite project, how to configure, run and test one gateway, and how to read its responses and audit records.

What is the Agent Gateway?

The IndyKite Agent Gateway (IAG) is a self-hosted proxy placed in front of one AI agent or one MCP server. On every request it authenticates the caller, establishes the chain of agents acting for the user, checks in the IndyKite Knowledge Graph that this chain is a modelled workflow the user may trigger, forwards the request with a delegation token, and writes an audit record. Nothing reaches the protected agent without passing the gateway. You run one gateway per protected agent.

user token
   │
   ▼
[orchestrator-iag] ──► orchestrator agent ──► [retriever-iag] ──► retriever agent
   introspect · exchange · check chain [orchestrator] against wf1 · authorize · audit
                                            introspect · exchange · check chain [orchestrator, retriever] · authorize · audit

Two protocols are understood, selected by protected_agent.protocol: a2a (Agent-to-Agent JSON-RPC, the default) and mcp (Model Context Protocol over Streamable HTTP). The Agent Gateway MCP tutorial walks the mcp mode end to end with a Google Drive server; this guide uses the a2a mode for its example and documents both.

Terms used in this guide

Term Meaning
IKGThe IndyKite Knowledge Graph: nodes and relationships loaded through the Capture API (Data Schema guide).
KBACKnowledge-Based Access Control: a policy is a graph pattern between a subject and a resource; the AuthZEN API answers "may this subject perform this action on this resource?" from it (AuthZEN guide).
ContX IQThe API that runs a stored Knowledge Query against the graph under a ContX IQ policy (ContX IQ guide). The gateway uses one query to read the workflows an agent belongs to.
A2AAgent-to-Agent protocol: JSON-RPC methods such as message/send exchanged between agents. MCP is the Model Context Protocol between an AI client and a tool server.
SubjectThe user on whose behalf the call is made: the sub claim of the tokens. Actor: an agent acting for the subject. The actors form the delegation chain.
Delegation tokenA token obtained by OAuth 2.0 token exchange (RFC 8693) whose act claim names the acting agent and nests the previous act, so act.sub, act.act.sub, ... is the chain, newest first.
Token ServiceThe optional self-hosted IndyKite issuer of delegation tokens (Token Service guide). Without it, your identity provider performs the exchange.
Application Agent credential tokenThe credential the gateway sends as X-IK-ClientKey to the IndyKite APIs; the agent needs the Authorization and ContXIQ API permissions (Credentials guide).
GIDThe platform identifier of a configuration object, prefixed gid:. Where a GID is accepted, the object's name usually is too.

How a request is decided

For every request, in this order:

  1. Bearer token. Authorization: Bearer <token> is required; without it the answer is 401. The token is introspected at the identity provider and must be active.
  2. Actor token. The gateway obtains a token for the protected agent with the client credentials grant, using protected_agent.authentication.
  3. Delegation token. Subject token and actor token are exchanged for one delegation token. Without a Token Service the identity provider does the exchange; with token_service configured, the Token Service does. The result must carry sub and an act object; the gateway reads the chain from it.
  4. Workflows of this agent. The ContX IQ Knowledge Query named in contx_iq.query_id is run with $agent_id set to the last agent of the chain (the protected agent). Each row names a workflow and its ordered agent list. The chain must be equal to the agent list of at least one workflow; otherwise the request is refused with 403 before any authorization call.
  5. Subject authorization. For each type in authzen.subject_types, the gateway calls POST /access/v1/search/resource with subject type and subject.id equal to the delegation token's sub, action authzen.action and resource type Workflow. The workflows that matched the chain and that the subject may trigger are the authorized ones; none means 403.
  6. Forward. Without a Token Service the delegation token replaces the caller's token in Authorization. With one, the caller's token stays in Authorization and the delegation token is added in X-IK-Token, where IndyKite policies read it as $ik_token.
  7. Audit. One record per request, written before forwarding, on every outcome including refusals.

Facts that follow from this sequence and matter when something is refused:

  • The subject is always the user. On later hops the token's sub is still the user's; an agent is the subject only when it starts a workflow with its own token.
  • The graph must hold a node of one of the authzen.subject_types whose external_id equals the token's sub. For a 2.0-kbac policy that node must be an identity node (is_identity: true). Otherwise every call ends in 403.
  • The agent names in the chain are the sub claims of the agents' client-credentials tokens, as the exchange writes them into act.sub. The Agent nodes' external_id must equal those values. Which value an identity provider puts in sub for a client-credentials token is the provider's choice; the IndyKite demos run on a provider that uses the client id.
  • The gateway calls AuthZEN and ContX IQ with X-IK-ClientKey only. The trigger policy and the workflow query never see the user token or the delegation token, so a KBAC policy that reads $token or $ik_token never matches here. Those claims are available to policies evaluated for calls the protected agent makes itself, when a Token Service forwards X-IK-Token.
  • A subject type without a matching policy returns an empty result, not an error. Each extra type costs one more AuthZEN call per cache miss.
  • Without a Token Service, the chain grows across hops only if the identity provider nests the previous act claim when the subject token is itself a delegation token. RFC 8693 allows this but does not require it; a provider that does not nest gives the second gateway a chain of one agent, which matches no two-agent workflow.
  • With a Token Service, an incoming X-IK-Token is validated there and becomes the subject of the next exchange; its sub must equal the sub of the Authorization token, otherwise 401. Without a Token Service an incoming X-IK-Token is ignored and removed from the forwarded request.

What is cached

  • The workflow list is cached per protected agent, the AuthZEN answer per subject, each for cache_ttl (default 5 minutes). A grant or revocation in the graph takes effect when the entry expires, not on the next call.
  • A failed platform call is cached as well: callers keep getting the error until cache_update_after_error (default 10 seconds) has passed and a reload succeeds.
  • Introspection, the client credentials grant and the token exchange are not cached; they run on every request.

From zero to a first authorized call

The example protects one agent, the retriever, called by a user through the orchestrator agent: the workflow wf1 is user -> orchestrator -> retriever. It is the shape the a2a/ demos of the developer-hub repository run. Six steps, in the order they must happen.

Step 1: Identity provider

The gateway works with any OAuth 2.0 provider that offers the three things below over HTTP Basic client authentication. Create one confidential client per agent, plus the client your users log in with.

The provider must Used for
Introspect tokens (RFC 7662) and report active and subEvery incoming user or delegation token.
Issue a token to a client with the client credentials grantThe protected agent's own token. Its sub is what appears in the delegation chain, so note it: it must be the agent's external_id in the graph.
Exchange a subject token and an actor token for a delegation token (RFC 8693) whose act.sub is the actor, and nest the subject token's own act when it has oneBuilding the chain hop by hop. If your provider cannot nest, run the Token Service, which does the exchange instead; the provider then only needs the first two.

For this example: client indykiteagent (orchestrator) and client indykiteagent-2 (retriever), each with a secret, and a login client for the user millicent. The provider also needs a Token Introspect configuration in your IndyKite project only if the protected agent itself calls IndyKite APIs with the forwarded token; the gateway does not need one.

Step 2: Graph data

Four kinds of nodes and two kinds of relationships, loaded with the Capture API and AppAgent credentials (X-IK-ClientKey). The user is an identity node whose external_id is the sub of their token; each agent's external_id is the sub of its client-credentials token.

POST /capture/v1/nodes
{
  "nodes": [
    {
      "external_id": "millicent",
      "type": "User",
      "is_identity": true,
      "properties": [
        {
          "type": "email",
          "value": "millicent@canbank.com"
        }
      ]
    },
    {
      "external_id": "wf1",
      "type": "Workflow"
    },
    {
      "external_id": "indykiteagent",
      "type": "Agent"
    },
    {
      "external_id": "indykiteagent-2",
      "type": "Agent"
    }
  ]
}

The workflow is a path of INVOKES relationships from the Workflow node through the agents in call order. Every relationship carries workflow_name equal to the workflow's external_id; that property is what keeps two workflows apart when they share agents. The user is linked to the workflow with the relationship the KBAC policy will match, here CAN_TRIGGER.

POST /capture/v1/relationships
{
  "relationships": [
    {
      "source": {
        "type": "Workflow",
        "external_id": "wf1"
      },
      "target": {
        "type": "Agent",
        "external_id": "indykiteagent"
      },
      "type": "INVOKES",
      "properties": [
        {
          "type": "workflow_name",
          "value": "wf1"
        }
      ]
    },
    {
      "source": {
        "type": "Agent",
        "external_id": "indykiteagent"
      },
      "target": {
        "type": "Agent",
        "external_id": "indykiteagent-2"
      },
      "type": "INVOKES",
      "properties": [
        {
          "type": "workflow_name",
          "value": "wf1"
        }
      ]
    },
    {
      "source": {
        "type": "User",
        "external_id": "millicent"
      },
      "target": {
        "type": "Workflow",
        "external_id": "wf1"
      },
      "type": "CAN_TRIGGER"
    }
  ]
}

One workflow per call shape. A workflow holds exactly one agent list. If the same agent is reached by two paths, model two workflows (for example wf3 for user -> analyst and wf3-console for user -> orchestrator -> analyst). When the query returns two rows for one workflow, the gateway keeps the last one and the other path is never authorized. The gateway also reads only the first page of the query result, 100 rows.

Step 3: KBAC policy for the trigger action

One policy per subject type listed in authzen.subject_types, with the action from authzen.action and resource type Workflow. Created with Service Account credentials:

POST /configs/v1/authorization-policies
Authorization: Bearer <service-account-token>
{
  "project_id": "gid:AAAABbbbCCCC...",
  "name": "user-can-trigger-workflow",
  "display_name": "User can trigger workflow",
  "policy": "{\"meta\":{\"policy_version\":\"2.0-kbac\"},\"subject\":{\"type\":\"User\"},\"actions\":[\"CAN_TRIGGER\"],\"resource\":{\"type\":\"Workflow\"},\"condition\":{\"cypher\":\"MATCH (subject:User)-[:CAN_TRIGGER]->(resource:Workflow)\"}}",
  "status": "ACTIVE"
}

Check it the way the gateway will, with the AppAgent credential:

POST /access/v1/search/resource
X-IK-ClientKey: <app-agent-credential-token>
{
  "subject": {
    "type": "User",
    "id": "millicent"
  },
  "action": {
    "name": "CAN_TRIGGER"
  },
  "resource": {
    "type": "Workflow"
  }
}
{
  "results": [
    {
      "type": "Workflow",
      "id": "wf1"
    }
  ]
}

Step 4: ContX IQ policy and Knowledge Query

The gateway runs the query as the application (_Application subject) with $agent_id set to the protected agent's name in the chain, and reads aggregate_values.workflow and aggregate_values.agent_list from each row. Any other shape is not understood. The INVOKES traversal is bounded to five hops; raise the bound if a chain is longer, since a longer chain is never matched.

POST /configs/v1/authorization-policies
Authorization: Bearer <service-account-token>
{
  "project_id": "gid:AAAABbbbCCCC...",
  "name": "get-agent-workflows",
  "display_name": "Get agent workflows",
  "policy": "{\"meta\":{\"policy_version\":\"1.0-ciq\"},\"subject\":{\"type\":\"_Application\"},\"condition\":{\"cypher\":\"MATCH (subject:_Application) MATCH (wf:Workflow)-[rels:INVOKES*1..5]->(a:Agent {external_id: $agent_id}) WHERE ALL(r IN rels WHERE r.workflow_name = wf.external_id AND endNode(r):Agent) WITH subject, wf.external_id AS workflow, [r IN rels | endNode(r).external_id] AS agent_list\",\"filter\":[]},\"allowed_reads\":{\"nodes\":[],\"relationships\":[],\"aggregate_values\":[\"workflow\",\"agent_list\"]}}",
  "status": "ACTIVE"
}
POST /configs/v1/knowledge-queries
Authorization: Bearer <service-account-token>
{
  "project_id": "gid:AAAABbbbCCCC...",
  "name": "get-agent-workflows",
  "display_name": "Get agent workflows",
  "policy_id": "<policy id from the previous call>",
  "query": "{\"aggregate_values\":[\"workflow\",\"agent_list\"]}",
  "status": "ACTIVE"
}

Check it for the retriever:

POST /contx-iq/v1/execute
X-IK-ClientKey: <app-agent-credential-token>
{
  "id": "get-agent-workflows",
  "input_params": {
    "agent_id": "indykiteagent-2"
  }
}
{
  "data": [
    {
      "aggregate_values": {
        "workflow": "wf1",
        "agent_list": [
          "indykiteagent",
          "indykiteagent-2"
        ]
      }
    }
  ]
}

A request is authorized when the token's chain equals one agent_list of a workflow the subject may trigger: [indykiteagent, indykiteagent-2] passes for wf1; [indykiteagent-2] alone or [indykiteagent, indykiteagent-3, indykiteagent-2] does not.

Step 5: Configure and run the gateway

The smallest useful configuration for the retriever's gateway, with audit records written to a file. Secrets are shown as placeholders; the file is not environment-expanded, so either write the values in or set them through JARVIS_ variables, which override the file (see Configuration).

service:
  name: retriever-iag
  port: 8888
identity_provider:
  base_url: https://idp.example.com/oauth/v2/
  introspect_endpoint: oauth-introspect
  client_credential_endpoint: oauth-token
  exchange_endpoint: oauth-token
protected_agent:
  base_url: http://retriever:6002
  protocol: a2a
  authentication:
    client_id: indykiteagent-2
    client_secret: <retriever client secret>
authzen:
  base_url: https://eu.api.indykite.com/access/v1
  action: CAN_TRIGGER
  subject_types:
    - User
contx_iq:
  base_url: https://eu.api.indykite.com/contx-iq/v1
  query_id: get-agent-workflows
  app_agent_credentials_token: <app agent credential token>
  allowed_workflow_id: wf1
audit:
  delivery: file
  file:
    storage_path: /app/audit
    format: json
    rotation_strategy: size-and-time
docker run -d \
  --name retriever-iag \
  -p 8888:8888 \
  -v $(pwd)/config.yaml:/app/config.yaml:ro \
  -v $(pwd)/audit:/app/audit \
  indykite/agent-gateway:2.65.2 \
  --config=/app/config.yaml

The audit directory must be writable by user 65532, the user the container runs as. The orchestrator gets its own gateway with client_id: indykiteagent and its own base_url; the orchestrator agent is configured to call the retriever through retriever-iag, not directly.

Step 6: Test

A probe without a token proves the gateway is up and enforcing, and writes an audit record:

curl -i -X POST http://localhost:8888/ -H "Content-Type: application/json" -d '{}'
# HTTP/1.1 401 Unauthorized
# {"message":"Missing bearer token"}

A real call needs a delegation token whose chain is [indykiteagent], the token the orchestrator receives from its own gateway. In the demos the orchestrator makes this call. To make it by hand, obtain a token for the user, exchange it at the provider with the orchestrator's client credentials as actor, and send the result:

curl -s -X POST http://localhost:8888/ \
  -H "Authorization: Bearer $DELEGATED_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "message/send",
  "params": {
    "message": {
      "role": "user",
      "messageId": "m-1",
      "parts": [
        {
          "kind": "text",
          "text": "Which policy documents pertain to refunds?"
        }
      ]
    }
  }
}'

On 200 the body is the retriever's JSON-RPC response. A user without the CAN_TRIGGER relationship, or a chain that is not [indykiteagent], gets:

{
  "message": "Authorization check failed"
}

The status alone does not say why. Read the audit record, whose reason does:

{
  "decision": "NOT_AUTHORIZED",
  "reason": "subject is not authorized to trigger any of the workflows wf1",
  "subject": "carol",
  "actor": "indykiteagent-2",
  "action": "TRIGGER",
  "service": "retriever-iag",
  "timestamp": "2026-09-30T09:12:00.123456789Z",
  "traceID": "4bf92f3577b34da6a3ce929d0e0e4736",
  "actorsChain": [
    "indykiteagent",
    "indykiteagent-2"
  ]
}

Troubleshooting

Symptom Cause Fix
401 {"message":"Unauthorized","errors":["identity provider: the token is not active"]}The user or delegation token is expired or unknown to the provider.Obtain a fresh token; check the provider's introspection endpoint in identity_provider.
401 Invalid token, missing act claimThe exchange returned a token without act: the provider did not perform a delegation, or the token sent was not exchanged.Confirm the provider's RFC 8693 support, or run the Token Service.
403, audit reason no workflow matches the actors chainThe chain (actorsChain in the record) equals no agent_list: the agents' external_id differ from the token subs, a hop is missing because the provider does not nest act, the workflow shape is not ingested, or allowed_workflow_id excludes it.Run the Knowledge Query by hand for the last agent and compare with the record.
403, audit reason subject is not authorized to trigger any of the workflows ...No policy grants the subject the action on those workflows, the subject node is missing or is not an identity node, or the type is not in authzen.subject_types.Run the AuthZEN resource search by hand for the subject.
Decision ERROR, reason cannot authenticate protected agentThe provider refused the client credentials grant.Check protected_agent.authentication and the client at the provider.
Decision ERROR, reason unable to fetch workflows or unable to authorize workflows for subjectContX IQ or AuthZEN could not be reached or refused the AppAgent credential. The error is cached for cache_update_after_error.Check app_agent_credentials_token, its API permissions and the region base URLs.
No audit records although JARVIS_AUDIT_* variables are setThe audit section must exist in a configuration file; environment variables only override its values.Mount a file with at least audit.delivery and pass --config.
Gateway starts, webhook auth fails, or the async queue is 512 whatever you setThe webhook auth and queue keys are read under the names given in the audit table below, not under the snake_case names used elsewhere.Use apikey, apikeyheader, mtlscertificatepath, mtlsprivatekeypath, buffersize.

Configuration

The gateway reads one YAML file passed with --config=<file>, or a directory of .yaml, .yml, .json or .toml files merged in alphabetical order with --config-dir=<dir>; the two flags are mutually exclusive. Rules that apply to every key:

  • Environment overrides. Upper-case the key path, replace dots with underscores, prefix JARVIS_: protected_agent.authentication.client_secret is JARVIS_PROTECTED_AGENT_AUTHENTICATION_CLIENT_SECRET. A variable replaces the file value. service.port also honours PORT.
  • Lists set from the environment are split on whitespace: JARVIS_AUTHZEN_SUBJECT_TYPES="User Person".
  • No expansion. ${VAR} in the file is a literal string. Put secrets in the file or in JARVIS_ variables.
  • The audit section is the one exception to "everything from the environment". Audit is enabled by the presence of audit in a file; variables cannot create it, only override its values. Every other section works from variables alone.
  • The gateway listens on plain HTTP. Terminate TLS in front of it. It calls the identity provider and the Token Service with HTTP Basic client authentication and a 10 second timeout, and the IndyKite APIs with a 10 second timeout.

service

Key Default Meaning
service.nameserviceName of this instance, written to every audit record and audit file name. Set it; the default is the binary name.
service.port8888Listening port.
service.healthcheck_port9080Port of /healthz, /readyz and /startupz, probed by the image's HEALTHCHECK.
service.environmentFree label such as prod or demo.
service.log_levelinfodebug, info, warn or error; JSON logs on standard output. At debug the log shows the chain and the platform answers per request.

identity_provider (required)

Key Meaning
identity_provider.base_urlProvider base URL; the endpoints below are joined to it.
identity_provider.introspect_endpointToken introspection endpoint.
identity_provider.client_credential_endpointToken endpoint for the client credentials grant.
identity_provider.exchange_endpointToken endpoint for the exchange grant, used when no Token Service is configured. Usually the same endpoint.

token_service (optional)

When present, the Token Service mints the delegation token and the gateway forwards it in X-IK-Token beside the caller's Authorization. All keys below are then required; identity_provider stays required for introspection and the actor token.

Key Meaning
token_service.base_urlWhere the gateway reaches the Token Service, e.g. http://token-service:8102.
token_service.exchange_endpoint, introspect_endpointoauth2/token and oauth2/introspect.
token_service.client_auth.typeOnly client_secret_basic.
token_service.client_auth.client_id, client_secretThe credentials the Token Service expects from gateways (its own idp.client_auth), not the identity provider credentials.

protected_agent (required)

Key Meaning
protected_agent.base_urlIn a2a mode, where the agent card is read; requests go to the JSON-RPC URL the card declares. In mcp mode, the downstream URL: a request to / uses it as is, including its path; any other request path replaces the path. With http://mcp:8080/mcp: / goes to /mcp, /mcp to /mcp, /?x=1 to /?x=1.
protected_agent.protocola2a (default) or mcp.
protected_agent.authentication.client_id, client_secretThe protected agent's client at the identity provider, used for the client credentials grant.
protected_agent.authentication.audiencesOptional list sent as audience on the exchange, at the identity provider or at the Token Service. Each must be in the Token Service's idp.audiences, otherwise the exchange is refused with invalid_target.

authzen (required)

Key Default Meaning
authzen.base_urlAuthZEN API of your region, e.g. https://eu.api.indykite.com/access/v1.
authzen.actionThe action the trigger policy grants on Workflow resources, e.g. CAN_TRIGGER.
authzen.subject_typesNode types a subject may have, e.g. User, Person. One resource search per type.
authzen.cache_ttl5mHow long a subject's answer is kept.
authzen.cache_update_aftercache_ttlAge after which the next request triggers a background reload. With the default it coincides with expiry, so the next request reloads synchronously. Must not exceed cache_ttl.
authzen.cache_update_after_errorsmaller of 10s and cache_update_afterHow long a failed answer is served before a retry. Must not exceed cache_update_after.

contx_iq (required)

Key Meaning
contx_iq.base_urlContX IQ API of your region, e.g. https://eu.api.indykite.com/contx-iq/v1.
contx_iq.query_idGID or name of the Knowledge Query of step 4.
contx_iq.app_agent_credentials_tokenThe Application Agent credential token, sent as X-IK-ClientKey to ContX IQ and to AuthZEN.
contx_iq.allowed_workflow_idOptional. Restrict this gateway to one workflow external_id; other rows of the query are skipped. Empty: every workflow the agent belongs to counts.
contx_iq.cache_ttl, cache_update_after, cache_update_after_errorSame defaults and rules as under authzen; the entry is per protected agent.

audit (optional, file only)

Without an audit section nothing is recorded. With one, records are always delivered through a background queue so requests never wait for the sink; a full queue drops records with the log line dropping payload, async queue is full, and the queue is drained on shutdown. The webhook authentication and queue keys are read under the compact names shown here; the snake_case spellings of other sections are not read for them.

Key Meaning
audit.deliveryfile or webhook; the matching sub-section is required.
audit.file.storage_pathDirectory for the files, named audit-<service.name>-.... Must be writable by user 65532.
audit.file.formatjson, csv or txt.
audit.file.rotation_strategytime, size or size-and-time. Required.
audit.file.rotation_interval, rotation_max_bytesDefaults 24h and 104857600 (100 MiB).
audit.http.url, audit.http.methodWebhook destination; method POST or PUT. Records are sent as JSON.
audit.http.auth.typeno-auth, api-key or mTLS.
audit.http.auth.apikey, apikeyheaderFor api-key; the header defaults to X-API-Key.
audit.http.auth.mtlscertificatepath, mtlsprivatekeypathFor mTLS; both required.
audit.async.buffersizeQueue length, default 512.

Startup errors

A configuration problem stops the process with one of these messages in the log.

Message Cause
invalid identity_provider configuration: missing <key>One of the four identity provider keys is absent.
invalid token_service configuration: missing <key>, client_auth of type "client_secret_basic" requires client_id and client_secret, client_auth has invalid type "…" (want "client_secret_basic")Incomplete Token Service section.
cannot get protected_agent base_url, invalid protected_agent protocol, missing client_id in protected_agent configuration, missing client_secret in protected_agent configurationIncomplete protected agent section.
missing required authzen.base_url, missing required authzen.action, missing required authzen.subject_typesIncomplete AuthZEN section.
missing required contx_iq.base_url, missing required contx_iq.query_id in configuration, missing required contx_iq.app_agent_credentials_token in configurationIncomplete ContX IQ section.
<section>.cache_ttl must not be negative, got …, <section>.cache_update_after (…) must not exceed <section>.cache_ttl (…), <section>.cache_update_after_error (…) must not exceed <section>.cache_update_after (…)Cache durations out of order, for authzen or contx_iq.
invalid port value … - not in rangeservice.port or service.healthcheck_port is not a valid TCP port.
audit: unknown delivery type "…", must be one of: file, webhook, audit: http url is required, audit: http method must be POST or PUT, got "…", audit: unsupported auth type "…", audit: api-key auth requires api_key, audit: mTLS requires mtls_certificate_file_path and mtls_private_key_path, audit: file storage path is required, audit: file rotation is required and must be one of "time", "size", "size-and-time"Incomplete audit section. The two auth messages name the keys in their snake_case form, but the values are read from apikey, mtlscertificatepath and mtlsprivatekeypath.

Deployment

The gateway ships as the Docker image indykite/agent-gateway: built from scratch with no shell, entrypoint /app/service so container arguments are the gateway flags, running as user 65532, published for linux/amd64 (add platform: linux/amd64 on Apple Silicon), with a HEALTHCHECK on http://localhost:9080/healthz every 10 seconds after a 30 second start period.

  • Pin a version tag from the tag list. A latest tag exists but lags weeks behind the newest release, so it silently gives an old image. The IndyKite demos pin the current release, 2.65.2 at the time of writing.
  • Version floors. MCP mode since 1.826.0; the health endpoints since 2.52.1, so older images fail the HEALTHCHECK; the MCP protocol-revision enforcement described below since 2.64.0.
  • Docker Compose. One pattern for several agents is a base service with the shared JARVIS_ variables (identity provider, IndyKite URLs, credential token, action, subject types), extended once per gateway with JARVIS_SERVICE_NAME, JARVIS_PROTECTED_AGENT_BASE_URL, the agent's client id and secret, and JARVIS_CONTX_IQ_ALLOWED_WORKFLOW_ID. Mount a small file with the audit section and pass --config, since audit cannot be enabled from variables. Complete stacks, with a Token Service and MCP-protecting gateways, are in the a2a/ folder of the developer-hub repository.
  • Kubernetes. A plain Deployment; no Helm chart is published. Mount the configuration file from a Secret at /app/config.yaml, or keep it in a ConfigMap with the secrets in JARVIS_ variables from a Secret. Run as a standalone Pod with its own Service, or as a sidecar in the agent's Pod with protected_agent.base_url set to http://127.0.0.1:<port> and only the gateway port exposed. Probes: /startupz, /readyz, /healthz on port 9080; runAsUser: 65532; a writable volume for file audit; egress to the identity provider, the IndyKite region, the protected agent and the audit webhook.

HTTP behaviour

a2a mode

  • JSON-RPC is accepted on any path and any method. Supported methods: message/send, message/stream, tasks/get, tasks/list, tasks/cancel, tasks/resubscribe, the four tasks/pushNotificationConfig/* methods and agent/getAuthenticatedExtendedCard.
  • The agent card at /.well-known/agent-card.json is not served: without a token 401, with one 400. A2A clients cannot discover the agent through the gateway; give them the card by other means.
  • message/stream and tasks/resubscribe answer 200 with text/event-stream before the agent is contacted; an unreachable agent or a later failure arrives as a JSON-RPC error frame inside the stream.

mcp mode

Any MCP method on any path is forwarded after the checks pass, with Mcp-Session-Id and the response body passed through unchanged, streamed for SSE. The downstream server's status is returned as is, for example 202 for a notification or 404 for an unknown session. Requests must be on protocol revision 2025-06-18, 2025-11-25 or 2026-07-28; the checks run after introspection and before authorization, in this order:

Refusal Status, JSON-RPC code, message
Content-Encoding other than identity415, -32600, Invalid Request: content coding <coding> is not supported
Body cannot be read400, -32700, Parse error: cannot read the request body
Body over 4 MiB413, -32600, Invalid Request: request body exceeds 4194304 bytes
Mcp-Protocol-Version present with an unsupported value, including on initialize400, -32022, Unsupported protocol version, with error.data.supported and error.data.requested
Header absent on any request other than initialize: GET for SSE, DELETE, notifications and client responses included400, -32020, Header mismatch: Mcp-Protocol-Version header is missing
JSON-RPC batch400, -32600, Invalid Request: JSON-RPC batching is not supported

An initialize without the header is forwarded; if its protocolVersion is unsupported it is rewritten to 2025-11-25. Invalid JSON with a valid header is forwarded and left to the server. The error body is a JSON-RPC response; id is present only when the request had one. Every refusal is audited as NOT_AUTHORIZED with reason MCP request refused: <message>. The MCP guide covers the revisions themselves.

Status codes

Status Body and meaning
200Authorized and forwarded; the body is the protected agent's or MCP server's response. In mcp mode any downstream status is passed through.
400a2a: the JSON-RPC request could not be parsed (empty body). mcp: one of the refusals above.
401{"message":"Missing bearer token"}; {"message":"Unauthorized","errors":["identity provider: the token is not active"]} or ["token service: the token is not active"]; {"message":"Invalid token, missing subject"}, {"message":"Invalid token, missing act claim"}, {"message":"Invalid token, act claim must be an object"} for a delegation token the exchange produced; {"message":"Invalid delegated token"}, {"message":"Invalid delegated token, missing subject"}, {"message":"Invalid delegated token, subject mismatch"} for an incoming X-IK-Token. A 401 or 403 from the identity provider, the Token Service, ContX IQ or AuthZEN is passed through with the same status and an errors entry naming the service, for example ["identity provider: token exchange failed - <error>: <error_description>"], ["identity provider: client credentials grant failed - …"], ["ContxIQ query execution failed"], ["searching resources through AuthZEN failed"]. So an invalid AppAgent credential reaches the caller as 401 or 403; the audit decision ERROR tells it apart from a denial.
403{"message":"Authorization check failed"}: the chain matches no workflow, or the subject may not trigger the matching ones. The audit reason says which.
405a2a: unknown JSON-RPC method (empty body).
404, 409, 415, 424, 501a2a: errors returned by the protected agent, translated: task or method not found, task not cancelable, unsupported content type, extended card not configured, unsupported operation.
500{"message":"Internal Server Error"}: the identity provider, the Token Service, ContX IQ, AuthZEN or an A2A agent could not be reached or timed out, or one of them answered 500.
502{"message":"Bad Gateway","errors":[…]}: in mcp mode the server could not be reached (the transport error is in errors) or base_url is not a URL (cannot resolve downstream URL); in a2a mode the agent returned a JSON-RPC internal error or an unusable response; the identity provider or Token Service answered with a malformed body.

Health for orchestrators is on port 9080. The unauthenticated 401 works as an external probe too, but every such probe writes an audit record and, with webhook delivery, one webhook call.

Audit records

One record per request, written before the request is forwarded, so a forward that fails afterwards still shows AUTHORIZED. JSON to the webhook, or the configured file format:

{
  "decision": "AUTHORIZED",
  "reason": "subject can trigger workflows wf1 and the actors in the delegation chain",
  "subject": "millicent",
  "actor": "indykiteagent-2",
  "action": "noop",
  "service": "retriever-iag",
  "timestamp": "2026-09-30T09:12:00.123456789Z",
  "traceID": "4bf92f3577b34da6a3ce929d0e0e4736",
  "actorsChain": [
    "indykiteagent",
    "indykiteagent-2"
  ]
}
Field Values
decisionAUTHORIZED; NOT_AUTHORIZED for every refusal, including a missing token and an MCP refusal; ERROR when the identity provider refuses the gateway's own credentials, when the delegation token lacks sub or act, or when ContX IQ or AuthZEN cannot be consulted.
reasonToken steps: no bearer token, cannot introspect the subject token, cannot validate the delegated token, invalid delegated token: missing subject, invalid delegated token: subject mismatch, cannot authenticate protected agent, cannot delegate token, invalid token: missing subject, invalid token: missing act claim, invalid token: act claim is not an object, MCP request refused: <message>. Authorization: the delegation chain is empty, unable to fetch workflows, no workflow matches the actors chain, unable to authorize workflows for subject, subject is not authorized to trigger any of the workflows <ids>, subject can trigger workflows <ids> and the actors in the delegation chain.
subject, actor, actorsChainThe user, the last agent, and the chain oldest first. In records written by the token steps the subject can be empty, actor is the upstream agent and actorsChain is the incoming chain, so it lacks the protected agent and is null on the first hop.
actionTRIGGER on the record that refuses the subject; noop otherwise.
serviceservice.name.
timestamp, traceIDRFC 3339 UTC with up to nanosecond precision (trailing zeros are dropped), and the trace ID also found in the gateway's JSON logs.
tokenIDOnly in Token Service records; the CSV header carries the column so both services can share files.

The txt format writes one line per record, values unquoted and the chain comma-joined:

[2026-09-30T09:12:00.123456789Z] decision=AUTHORIZED reason=subject can trigger workflows wf1 and the actors in the delegation chain subject=millicent actor=indykiteagent-2 actorsChain=indykiteagent,indykiteagent-2 action=noop service=retriever-iag traceID=4bf92f3577b34da6a3ce929d0e0e4736 tokenID=

csv writes the same columns in that order with a header row per file.

Related