Skip to content

Agent host contract

An Agent Host Protocol (AHP) host runs many agent sessions across harnesses (Claude, Codex, Calliope AI) and lets several clients attach to each one. Any attached client may confirm a pending tool call, so enforcement sits in the host: before a tool call reaches its clients, the host's gate asks Zentinelle. This page is the contract between that gate and Zentinelle: identity, the tool_call evaluation, and the oversight hold (#377).

The gate is additive. Hooks, the gateway and the SDKs keep governing sessions outside the host exactly as before.

Identity: one key per host

A host registers once, as agent_type: agent_host, and keeps its sk_agent_ key in the host process. Harness processes never see it. Sessions are not registered; every evaluation names its harness, session and chat in context.

POST /api/zentinelle/v1/register
X-Zentinelle-Bootstrap: bt_<tenant_id>_<signature>
Content-Type: application/json

{"agent_id": "agenthost-dev", "agent_type": "agent_host", "name": "Agent host (dev)"}
{
  "agent_id": "agenthost-dev",
  "api_key": "sk_agent_...",
  "config": {"heartbeat_interval_seconds": 60, "event_batch_size": 100, "event_flush_interval_seconds": 5, "config_refresh_interval_seconds": 300},
  "policies": []
}

A platform service can create the same identity with POST /api/zentinelle/v1/operator/agents and a platform or service key with the write scope.

Registering each session through /register was rejected. Sessions are short-lived and numerous, so session churn would become endpoint and key churn, every session would consume an agent entitlement, and each session's key would have to live inside the harness process the gate is policing. With one key per host, endpoint-scoped policies apply to every session the host runs, user-scoped policies follow the top-level user_id, and per-session attribution comes from context.

Evaluating a tool call

POST /api/zentinelle/v1/evaluate with the host key in X-Zentinelle-Key.

Field Required Meaning
action yes tool_call
user_id recommended The person driving the session. Approvals bind to it.
context.harness yes The provider inside the host, as a lowercase slug of up to 50 characters: claude, codex, calliope.
context.session_id yes The AHP session. Identifiers take up to 255 characters.
context.chat_id no The chat within the session.
context.tool_call_id no The pending tool call. Recommended: it binds an approval to this one call.
context.tool_name yes The tool, as the harness names it.
context.tool_input no The tool's arguments. Bound into approvals and stored only when content capture allows. Command and path rules read them; see rules on tool arguments.
{
  "action": "tool_call",
  "user_id": "dev@example.com",
  "context": {
    "harness": "claude",
    "session_id": "a41f0c2e",
    "chat_id": "a41f0c2e-1",
    "tool_call_id": "toolu_01",
    "tool_name": "Bash",
    "tool_input": {"command": "npm test"}
  }
}

Rules on tool arguments

A tool_permission policy names argument rules per tool under tool_configs, keyed by the tool's name as the harness sends it (#384):

{
  "tool_configs": {
    "Bash": {"blocked_commands": ["rm -rf *"], "allowed_commands": ["npm test", "npm run *"]},
    "Write": {"allowed_paths": ["/workspace"], "blocked_paths": ["/workspace/.git"]}
  }
}

With that policy, {"tool_name": "Bash", "tool_input": {"command": "rm -rf /"}} is denied and {"command": "npm test"} is allowed.

  • Commands come from command or cmd, a string or an argv list. The command is split into simple commands at ;, &&, ||, |, &, newlines, parentheses, backticks and $(, and the script of sh -c, bash -lc and the like, or of eval, is split the same way. Quotes are removed and whitespace collapsed, then each simple command is matched against glob patterns (* matches anything, spaces included).
  • A blocked pattern refuses the call if it matches any simple command, or its tail from any word on, so rm -rf * also refuses sudo rm -rf /. It matches text, not meaning: rm -fr /, or a command assembled from variables, is a different text. Blocked patterns are a guard.
  • With allowed patterns, every simple command must match one in full, so anything not listed is refused. That is the boundary. For allowed patterns the command is read as a shell reads it, and what Zentinelle could read differently from a shell is refused: command substitution, or a backslash before $, a backtick or a newline, inside double quotes; $'...' quoting; here-documents. Process substitution, <(...) and >(...), is a separate command. A call carrying both command and cmd must pass with each.
  • Paths come from file_path, path, notebook_path, paths and the file headers of an apply_patch patch, and a call with none is checked by its working directory. A relative path resolves against workdir or cwd in the arguments, else context.cwd. A path is inside a listed prefix when it is the prefix or below it after . and .. are resolved. Every path must be inside an allowed prefix and none inside a blocked one. Symbolic links are not visible to Zentinelle.
  • A command that cannot be parsed, a call with no command or path to check, and a relative path with no working directory where a rule names absolute paths are refused.
  • A call its arguments refuse is deny, never ask: no approval can release it.

The rules read context.tool_input and context.tool_args, the older field; a call carrying both must pass with each.

A host tool call missing harness, session_id or tool_name, or with a malformed one, is refused before evaluation:

{"error": "Invalid agent host tool_call context", "context": {"session_id": ["This field is required."]}}

Decisions

decision allowed The host
allow true Continues through its normal confirmation policy.
deny false Rejects the tool call and shows reason to every client.
ask false Holds the tool call pending; see the oversight hold.

allowed is true only for allow, so a client that reads only allowed treats ask as a refusal.

allow:

{
  "contract_version": "1",
  "subject": {"tenant_id": "acme", "user_id": "dev@example.com", "endpoint_id": "6d0c4a0e-8a3b-4f8e-9d1c-2b7e5f9a1c33", "agent_id": "agenthost-dev"},
  "action": "tool_call",
  "resource": {"type": "tool", "id": "Bash"},
  "context": {"harness": "claude", "session_id": "a41f0c2e", "chat_id": "a41f0c2e-1", "tool_call_id": "toolu_01", "tool_name": "Bash", "tool_input": {"command": "npm test"}, "resource_type": "tool", "resource_id": "Bash", "trace_id": "0b6f3a52-5d0e-4f7e-9a8e-2f1d6c9b7e41", "taxonomy": {"supported": [], "unsupported": []}},
  "trace_id": "0b6f3a52-5d0e-4f7e-9a8e-2f1d6c9b7e41",
  "decision": "allow",
  "allowed": true,
  "reason": null,
  "policies_evaluated": [],
  "coverage": {"status": "unknown", "counts": {"enforced": 0, "observation_only": 0, "unsupported": 0}},
  "warnings": [],
  "output_filter_required": false
}

deny (the remaining fields as above):

{
  "decision": "deny",
  "allowed": false,
  "reason": "Tool 'Bash' is explicitly denied",
  "policies_evaluated": [{"id": "c2f5...", "version": 3, "name": "No shell", "type": "tool_permission", "result": "fail", "message": "Tool 'Bash' is explicitly denied", "matched_selectors": [], "coverage": "enforced"}]
}

ask (the remaining fields as above):

{
  "decision": "ask",
  "allowed": false,
  "reason": "Tool 'Bash' requires approval. Request approval before proceeding.",
  "policies_evaluated": [{"id": "8f08...", "version": 1, "name": "Shell needs a human", "type": "tool_permission", "result": "fail", "message": "Tool 'Bash' requires approval. Request approval before proceeding.", "matched_selectors": [], "coverage": "enforced"}],
  "approval": {
    "request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10",
    "status": "pending",
    "expires_at": "2026-09-22T20:05:00.412345Z",
    "timeout_seconds": 300
  }
}

ask is returned when every enforced failure is a missing human approval, and only to agent_host endpoints. Other agent types keep receiving deny for the same policies. A call that any other rule refuses is deny, even when it also needs approval. The policies that can ask are tool_permission (requires_approval), agent_capability (require_approval) and human_oversight (require_approval_for).

The oversight hold

  1. /evaluate answers ask with approval.request_id. Zentinelle opens an approval request.
  2. The host keeps the AHP tool call pending. A confirmation from an attached client does not release it; only an approval does.
  3. The host polls GET /api/zentinelle/v1/approvals/requests/{request_id} with its key, every 2 seconds or so, until the status leaves pending or timeout_seconds have passed on its own clock.
  4. An operator lists pending requests and approves or denies this one; see deciding.
  5. On approved, the host sends the same evaluation again with context.approval_token added. allow consumes the approval and the call proceeds; deny rejects it.
  6. On denied, expired, or its own timeout, the host rejects the call with the reason.

The window is the shortest approval_timeout_seconds among the host's effective policies, at most 300 seconds. After approval, the token is valid for one use until approval_expires_at, also at most 300 seconds.

The approval is bound to the host, the user, the action, the current policy versions and a digest of every context field except approval_token, request_id and trace_id. The retry must repeat them exactly; a different session, tool, input or tool_call_id is denied. The retry is a full evaluation, so a policy that changed since the request was opened can still refuse it. It never answers ask again: once a token is presented, a failed approval check is a denial.

Each ask opens a new request, so hold one call per request.

Polling

Only the host whose call is held can read the request; any other key receives 404.

{"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10", "status": "pending", "expires_at": "2026-09-22T20:05:00.412345Z"}
{
  "request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10",
  "status": "approved",
  "expires_at": "2026-09-22T20:05:00.412345Z",
  "approval_token": "IjRlMTIy...:1x9CZZ:r4sgb8...",
  "approval_expires_at": "2026-09-22T20:06:12.004211Z"
}
{"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10", "status": "denied", "expires_at": "2026-09-22T20:05:00.412345Z", "reason": "Not on the release branch"}
{"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10", "status": "expired", "expires_at": "2026-09-22T20:05:00.412345Z", "reason": "No approval arrived before the request expired"}

The retry that releases an approved call:

{
  "action": "tool_call",
  "user_id": "dev@example.com",
  "context": {
    "harness": "claude",
    "session_id": "a41f0c2e",
    "chat_id": "a41f0c2e-1",
    "tool_call_id": "toolu_01",
    "tool_name": "Bash",
    "tool_input": {"command": "npm test"},
    "approval_token": "IjRlMTIy...:1x9CZZ:r4sgb8..."
  }
}

Deciding

Operators decide in a portal session. Workload keys cannot call these endpoints (401), so no agent can approve its own work.

Open Governance → Approvals (/approvals) in the portal. The inbox shows the newest pending requests, their agent, user, harness, session, policy reason and remaining time. It refreshes every 15 seconds. Viewers can inspect requests; operators and administrators can approve or deny, optionally recording a reason of up to 1,000 characters. The server checks tenant access, role and expiry again when saving the decision. An expired request cannot be approved.

Expand Action details to inspect retained arguments or delegation scope and budgets. When arguments are absent, the page explains metadata-only capture and links to Settings. It does not reconstruct arguments that were never retained. The inbox uses the human session and CSRF protection; it never requests or displays the workload's approval token. If a decision cannot be confirmed, refresh the inbox before trying again: the original submission might have been saved.

GET /api/zentinelle/v1/approvals/requests (viewer role or above) lists the tenant's pending, unexpired requests, newest first, up to 100:

{
  "requests": [
    {
      "request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10",
      "agent_id": "agenthost-dev",
      "user_id": "dev@example.com",
      "action": "tool_call",
      "context": {"harness": "claude", "session_id": "a41f0c2e", "chat_id": "a41f0c2e-1", "tool_call_id": "toolu_01", "tool_name": "Bash", "resource_type": "tool", "resource_id": "Bash", "trace_id": "0b6f3a52-5d0e-4f7e-9a8e-2f1d6c9b7e41", "taxonomy": {"supported": [], "unsupported": []}},
      "reason": "Tool 'Bash' requires approval. Request approval before proceeding.",
      "trace_id": "0b6f3a52-5d0e-4f7e-9a8e-2f1d6c9b7e41",
      "expires_at": "2026-09-22T20:05:00.412345Z",
      "created_at": "2026-09-22T20:00:00.412345Z"
    }
  ]
}

The context shown follows the tenant's content capture mode. Under metadata, the default, tool_input is not stored, so the approver sees which tool in which session but not its arguments; set content_capture_mode to redacted or full to show them.

POST /api/zentinelle/v1/approvals/requests/{request_id}/decision (operator role or above, with the CSRF token):

{"decision": "approve", "reason": "Expected test run"}
{"request_id": "5a0d9f3e-0c77-4c1b-9d52-7f4b8f0f3e10", "status": "approved", "decided_at": "2026-09-22T20:01:12.004211Z"}

decision is approve or deny. The response is 404 for a request outside the operator's tenant and 409 once the request is decided or expired. Each decision is written to the audit log.

Failing closed

Anything other than a 200 with decision: allow means the call does not run:

Response Meaning
400 The request or the host context is invalid.
401 The key is missing, unknown, expired, suspended or revoked.
403 agent_id in the body does not match the key.
503 Policy evaluation is unavailable. The body is a structured deny with reason: "Policy evaluation unavailable".
No response in time Zentinelle is unreachable. The host denies.

Every evaluation is recorded as an event for the host's endpoint, with its session context. A held call is recorded like any refusal, a policy_violation event, with result.decision: "ask" and approval_request_id in its payload.

OTLP traces from the agent host

POST /api/zentinelle/v1/otlp/v1/traces accepts OTLP/HTTP JSON trace batches with the same X-Zentinelle-Key authentication as /events. The authenticated agent endpoint determines the tenant and agent; telemetry resource attributes cannot select either. A successful ingest returns HTTP 200 and {}. Protobuf and gRPC are not supported by this receiver.

Each span becomes an agent_host.span telemetry event. Trace ID and span ID form its producer event ID, so exporter retries are idempotent and conflicting replays are refused. The whole batch is validated before any event is stored. The receiver accepts at most 1,000 spans per request. The existing ingest content-capture policy and processing queue apply unchanged.

The host's vscode.agent_host.session.uri attribute becomes payload.context.session_id, matching the session on policy evaluations and approval requests. Span name and arbitrary attributes are content, omitted under the default metadata capture policy. Trace IDs, span IDs, duration and status remain available for session correlation.

Configure the host's native exporter with OTEL_EXPORTER_OTLP_PROTOCOL=http/json, OTEL_EXPORTER_OTLP_ENDPOINT=<zentinelle-origin>/api/zentinelle/v1/otlp/v1/traces, and OTEL_EXPORTER_OTLP_HEADERS=X-Zentinelle-Key=<agent-host-key>. Keep OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false unless the operator explicitly enables content capture. This receiver needs no separate collector, observer or process; it uses the existing tenant event pipeline.

Astrolift pre-approval channel

The minting install evaluates tool calls at POST /api/zentinelle/v1/astrolift/agents/{agent_id}/evaluate with tenant_id, action: tool_call, the task initiator's user_id, and host context. The endpoint must belong to that install and tenant; revoked or expired agent credentials refuse evaluation. No agent key is distributed to the host.

An ask result identifies a held approval request. Astrolift checks its current controller permission before posting tenant_id, decision: approve|deny, actor_id, and an optional reason to /astrolift/agents/{agent_id}/approvals/{request_id}/decision. The decision is bound to the endpoint and exact tool context, audited with the install and controller identities, and identical retries reuse the single-use grant. Astrolift evaluates again with the returned approval_token; current policies can still deny. Network failures keep the runner blocked.

Observed sessions and enforcement receipts

The durable Astrolift AUDIT.agent.session.event bridge carries recorded runner events, scoped to the enrolled install, tenant, registered cluster and minted agent endpoint. Task, turn, tool, team and project identities preserve evidence correlation. Declared agent/spec/tool-preset intent accompanies observed tool names. Prompt, tool-input and output content is omitted by the bridge; Zentinelle also applies its content-capture settings before persisting evidence.

Observed tool input and reported approval holds run through the existing policy engine and shared action set. A decision produces an install-scoped durable action, without pretending the observed event preceded execution. Audit policy mode limits the action to observation. Unsupported runtime controls must report the actual fallback and outcome.

An install polls GET /api/zentinelle/v1/astrolift/agents/{agent_id}/enforcements with its enrollment Bearer credential and tenant_id query parameter. Pending actions have a fresh nonce and 60-second expiry on each delivery. The signature is sha256= followed by HMAC-SHA256 over UTF-8 JSON (sorted keys, compact separators, no NaN), using SHA256 of the presented install credential as the key. The body binds the action ID, install, tenant, cluster, agent and task/box target. Zentinelle stores the credential hash, and signs only on that authenticated polling channel; it does not recover or retain plaintext enrollment credentials.

The install reports a bounded terminal outcome to POST /api/zentinelle/v1/astrolift/agents/{agent_id}/enforcements/{action_id}/outcome with tenant_id and outcome. Status is applied, observed, refused or failed. An identical receipt is idempotent; a conflicting receipt returns 409. Evidence and policy links accompany the audit record. Other installs cannot read or acknowledge the action. These endpoints are install operations, not AHP client events or user authorization grants.

Hub tap identity (#415)

A multiuser hub keeps its agent-host key in the hub tap. Configure the trusted AgentEndpoint config.hub_identity with hub_id and api_url (the hub's /hub/api URL). The backend calls that URL itself, so it must resolve to public addresses only, or its host must be listed in HUB_IDENTITY_ALLOWED_HOSTS (comma-separated; for an in-cluster hub). Other URLs are refused when the endpoint is saved and when it is used. The tap forwards the caller's hub token as X-JupyterHub-User-Token. Zentinelle verifies it against that configured hub on every evaluation, refuses service tokens and tokens without access to the user's own server, and replaces claimed user/context fields with the verified hub:<hub_id>:<username> identity. Missing, revoked, redirected, or unreachable identity checks refuse the evaluation; there is no cache or claimed-ID fallback. Use TLS between services outside a trusted private network. A hub header on an endpoint without this administrative binding is also refused. Single-user agent-host endpoints without a binding retain their existing contract.