Core API

Agent identity, traceability & signed evidence

Know which agent did what: identity from the credential, sub-agents that register themselves, one trace per run with every tool call, hooks and OpenTelemetry that report without relying on the model, and HMAC-signed evidence.

Updated Sep 28, 2026

How an agent and its sub-agents are identified, how a run is traced end to end, how to report every tool call without trusting the model, how Contro1 notices an agent that stopped reporting, and how to verify the signed evidence.

Key takeaways

  • The credential is the identity. A named part of an agent says who it is with the Contro1-Sub-Agent header and needs no setup.
  • One trace_id per run. Actions Contro1 ran through the Gateway appear in the trace on their own, marked as executed by Contro1.
  • Report from a hook in the runtime, not from the model: ready-made for LangGraph, OpenAI Agents, Strands and Claude Code, or send OpenTelemetry.
  • An agent that asks for approvals but reports no steps is flagged in Agent Inventory.

The ladder

Adopt it one rung at a time. Each step is a small addition to what you already send.

StepWhat you addWhat it gives you
1. IdentityAn Agent Credential for each agentEvery call is attributed to that agent, and nothing in a request body can change which agent it is.
2. Sub-agentsThe Contro1-Sub-Agent header, or client.as_sub_agent(name)Each part of a multi-agent system shows up under its parent, with its own trail.
3. One run, one tracetrace_id, and parent_trace_id for a sub-agent's runEvery step of a run on one timeline, as a tree when agents call agents.
4. What it didA hook that reports every tool callEvery tool it called, with input, outcome and timing, without trusting the model to say so.
5. Why it did itcontext.machine_observed, context.agent_reported, retrieved_context[]What triggered the step, the data it used, and its own explanation, labelled apart.
6. EvidenceGET /v1/requests/:id/evidenceA signed record of identity, action, context and decision you can verify.

Identity: the credential is the agent

Give each agent its own Agent Credential (Settings, Agent keys, Add agent key) or connect it with contro1 connect, which holds a key on the machine. Contro1 reads the agent from the credential on every call.

A request or audit record may repeat actor.agent_id for readability, but it must match the credential. A different value is refused with HTTP 403 agent_identity_mismatch, and on a runtime connection the attempt is itself recorded. One agent cannot write into another agent's trace by naming it.

Naming a different agent is refused
curl -X POST https://api.contro1.com/api/centcom/v1/audit-records \
  -H "Authorization: Bearer $CONTRO1_AGENT_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{ "action": "email.sent", "summary": "...", "source": { "integration": "langgraph" },
        "actor": { "agent_id": "some-other-agent" } }'

# HTTP 403
# { "error": "agent_identity_mismatch",
#   "message": "actor.agent_id must match the authenticated agent credential. To act as a named
#               part of this agent, send the Contro1-Sub-Agent header instead." }

Sub-agents

Most multi-agent systems run in one process with one credential: a supervisor and its workers in LangGraph, a CrewAI crew, the subagents Claude Code starts. Each part says who it is with one header on the parent's credential, and Contro1 registers it under the parent the first time it is seen. There is nothing to set up.

A part can never do more than its parent. It runs on the parent's grants, its owner is the parent's owner, and blocking the parent blocks every part of it. On the org map and in Agent Inventory each part is drawn under its parent. A new part is marked with an orange ! until an administrator approves it; it can already act within its parent's authority meanwhile, so the mark asks for a look rather than stopping anything.

A sub-agent that runs as a separate program, somewhere else, with its own credential, is registered on its own and linked to its parent: contro1 init --parent <agent_id>, or register_agent with parent_agent_id. It appears under the parent, gets its own grants and inherits nothing. Only someone who owns or built the parent can link to it.

The sub-agentHow it identifies itselfIts authority
Runs inside the parent's processContro1-Sub-Agent: <name> with the parent's credentialThe parent's, narrowed by anything set on the part. Never more.
Runs as a separate programIts own Agent Credential, registered with a parentIts own grants. Nothing is inherited.
crew.py
from centcom import CentcomClient

client = CentcomClient(api_key=os.environ["CONTRO1_AGENT_CREDENTIAL"])

researcher = client.as_sub_agent("researcher")
writer = client.as_sub_agent("writer")

researcher.log_action(action="tool.search", summary="Searched the KB", source={"integration": "crewai"})
writer.actions.invoke("gmail.message.send", {...}, "agent_principal", "shared",
                      idempotency_key=str(uuid.uuid4()))   # runs on the parent's grants
crew.ts
import { CentcomClient, ActionsApi } from "@contro1/sdk";

const client = new CentcomClient({ apiKey: process.env.CONTRO1_AGENT_CREDENTIAL! });
const researcher = client.asSubAgent("researcher");

await researcher.logAction({ action: "tool.search", summary: "Searched the KB", source: { integration: "mastra" } });
await new ActionsApi(researcher).invoke({ /* runs on the parent's grants */ });
terminal
curl -X POST https://api.contro1.com/api/centcom/v1/audit-records \
  -H "Authorization: Bearer $CONTRO1_AGENT_CREDENTIAL" \
  -H "Contro1-Sub-Agent: researcher" \
  -H "Content-Type: application/json" \
  -d '{ "action": "tool.search", "summary": "Searched the KB", "source": { "integration": "custom" } }'

Tracing a run

trace_id (format trc_...) groups everything from one run: approval requests, reported steps, and the Actions Contro1 ran through the Gateway. parent_trace_id attaches a sub-agent's run to its parent. GET /v1/traces/:trace_id returns the whole run: the timeline, the tool calls and the sub-agents.

Every tool call in a trace says where it came from. gateway_verified means Contro1 made the call itself with an account it holds. client_reported means the agent's code sent it. The trace page shows the two apart, and that difference is the evidence.

Create the trace_id once, when the run starts, and pass it to every call the run makes. trace_id is not thread_id (a conversation) or correlation_id (a business case); send whichever you have.

An approval request that carries the run
{
  "request_type": "approval",
  "title": "Refund $240 to customer 8831",
  "source": { "integration": "langgraph", "run_id": "run_5521" },
  "trace_id": "trc_9f2c41",
  "tool_calls": [
    { "name": "lookup_order", "input": { "order_id": "1842" }, "outcome": "success",
      "output_summary": "Order 1842, charged twice on 12 Sep" }
  ],
  "retrieved_context": [
    { "source": "policy:refunds", "uri": "kb://policies/refunds", "score": 0.91 }
  ],
  "context": {
    "machine_observed": { "triggered_by": "Support ticket 5521", "tool": "issue_refund", "amount": 240 },
    "agent_reported": { "reason": "The customer was charged twice for the same order." }
  },
  "continuation": { "mode": "decision" }
}

What happened, and why: provenance

Two kinds of statement go into a trace, and an auditor has to be able to tell them apart. Machine-observed facts are what your code captured from the runtime: the tool it called, the input, the output, the event that started the run. Agent-reported text is what the model says about itself, such as why it chose a tool.

Send the first in tool_calls and context.machine_observed, and the second in context.agent_reported. Agent-reported text is capped shorter on purpose, and it informs the reviewer only: it never raises or lowers routing, risk_level or approval policy.

Contro1 does not capture a model's private reasoning. If you want the agent's explanation, make it a required argument of the risky tool, for example issue_refund(order_id, amount, reason), so it is written at the moment of the decision and travels with the call.

Reporting every tool call, without trusting the model

A model told to "always report" can skip it, forget it, or describe something else. Report from the code around the model instead: a hook that runs before and after every tool call, which the model cannot see and cannot skip. Use the one for your framework.

RuntimeWhat to addNotes
LangChain, LangGraphfrom centcom.hooks.langchain import Contro1TraceHandler, then config={"callbacks": [handler]}One handler per run.
OpenAI Agents SDKfrom centcom.hooks.openai_agents import Contro1RunHooks, then Runner.run(..., hooks=hooks)per_agent=True reports each agent in the run as a sub-agent.
Strands Agentsfrom centcom.hooks.strands import Contro1HookProvider, then Agent(hooks=[provider])
CrewAIfrom centcom.hooks.crewai import Contro1CrewHooks, then with Contro1CrewHooks(client): crew.kickoff()per_agent=True reports each crew member as a sub-agent. centcom 1.7.0.
Pydantic AIfrom centcom.hooks.pydantic_ai import contro1_toolset, then Agent(toolsets=[contro1_toolset(tools, client)])centcom 1.7.0.
Mastra (and Vercel AI SDK tools)import { traceTools } from "@contro1/sdk", then new Agent({ tools: traceTools(run, tools) })@contro1/sdk 1.7.0.
n8nThe "Contro1: report a step" workflow from the centcom-n8n repo, called before and after a stepOne execution is one trace. On Error: Stop Workflow fails closed.
Claude Codecontro1 activity hook in PreToolUse and PostToolUseOne session is one trace. contro1 0.2.19 or later.
Any Python or TypeScript codeTraceRun: run.wrap(tool) or with run.tool(name, input)run.sub_agent(name) for a part, as a child run.
Already on OpenTelemetryAn OTLP exporter pointed at Contro1See below. No code of ours.
graph.py
from centcom import CentcomClient
from centcom.hooks.langchain import Contro1TraceHandler

client = CentcomClient(api_key=os.environ["CONTRO1_AGENT_CREDENTIAL"])
handler = Contro1TraceHandler(client, fail_closed=True)

graph.invoke(inputs, config={"callbacks": [handler]})
print(handler.trace_id)   # read the run back at /traces/<trace_id>
run.py
from agents import Runner
from centcom import CentcomClient
from centcom.hooks.openai_agents import Contro1RunHooks

client = CentcomClient(api_key=os.environ["CONTRO1_AGENT_CREDENTIAL"])
hooks = Contro1RunHooks(client, fail_closed=True, per_agent=True)

await Runner.run(triage_agent, "Refund order 1842", hooks=hooks)
.claude/settings.json
{
  "hooks": {
    "PreToolUse":  [{ "matcher": "*", "hooks": [{ "type": "command", "command": "contro1 activity hook --fail-closed" }] }],
    "PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "contro1 activity hook" }] }]
  }
}
tools.ts
import { CentcomClient, TraceRun } from "@contro1/sdk";

const client = new CentcomClient({ apiKey: process.env.CONTRO1_AGENT_CREDENTIAL! });
const run = new TraceRun(client, { source: "mastra", failClosed: true });

const lookupOrder = run.wrap("lookup_order", rawLookupOrder);   // start and end reported
const researcher = run.subAgent("researcher");                  // a part, as a child run

Sending OpenTelemetry traces

If your agent is already instrumented with OpenTelemetry, point an OTLP exporter at Contro1 and its runs arrive with no code of ours. Contro1 reads the GenAI semantic conventions: a span with gen_ai.operation.name execute_tool, or with gen_ai.tool.name, becomes a tool call, with gen_ai.tool.call.arguments and gen_ai.tool.call.result when present.

Each export batch becomes one reported step per trace, with the OpenTelemetry trace id as trc_<32 hex>. Only the tool call fields, span names and model names are kept. Other attribute values, such as prompts and completions, are not stored.

OTLP exporter settings
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://api.contro1.com/api/centcom/v1/otel/v1/traces
OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer <Agent Credential>"
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf  # the default; http/json works too

Other ways to report a step

  • The SDK directly: client.log_action(...) with trace_id, parent_trace_id and tool_calls, or POST /v1/audit-records.
  • A script or CI job: contro1 activity report --file event.json, with the agent's credential.
  • An MCP client with no code of yours around it: the tool report_activity, which accepts trace_id, parent_trace_id, tool_calls and sub_agent. The model decides when to call it, so treat what it sends as agent-reported.

Making sure the agent reports

An instruction in a prompt or a skill is not enforcement. Four controls do hold, from strongest to weakest.

  • Run the action through Contro1. When an application Action goes through the Action Gateway, the agent holds no credential for that application, so the only way it can act is through Contro1, and Contro1 records the call itself. That record is gateway-verified, not self-reported.
  • Report from a hook that fails closed. It runs outside the model around every tool call, and if the report to Contro1 fails, the tool does not run.
  • Watch coverage. Agent Inventory flags an agent with "No traces reported" when it asked for approvals in the last 7 days but reported no steps of its own: its hook is missing or broken. Gateway actions do not count against it, because Contro1 recorded those itself.
  • Read the traces back. GET /v1/traces/:trace_id shows what arrived for a run.

Least agency: what an agent may do

For application Actions, a grant in the agent's capsule is the only thing that authorizes. The agent's scopes can only narrow it: allowed_action_ids is a ceiling, and an action in the ceiling with no grant is still refused. For a sub-agent that runs as part of its parent, both the parent's ceiling and its own apply.

For approval requests, the agent's scopes bound what it may ask for: allowed_action_types, allowed_department_ids (who it may act for), max_risk_level, and require_approval_above, which forces a human decision above that risk. Anything out of scope is refused with HTTP 403 agent_policy_blocked. There is no advisory mode: a ceiling that only warned let a critical action through and flagged it afterwards, so it was removed.

Action Gateway: grants and the capsule

Discovery and the blocklist

An agent Contro1 has seen but nobody has verified is shown as claimed in Agent Inventory, so it is visible rather than silently trusted. An administrator verifies it, gives it an owner and scopes, or blocks it. A blocked agent, and every part of it, is refused on every call with HTTP 403 until it is unblocked.

Verify, block or unblock an agent
# Administrator session
curl -X POST https://api.contro1.com/api/centcom/admin/agents/agt_1a2b/verify
curl -X POST https://api.contro1.com/api/centcom/admin/agents/agt_1a2b/block \
  -H "Content-Type: application/json" \
  -d '{ "reason": "unrecognized source, investigating" }'
curl -X POST https://api.contro1.com/api/centcom/admin/agents/agt_1a2b/unblock

# A blocked agent's next call:
# HTTP 403 { "error": "agent_policy_blocked", "policy_reason": "agent_blocked", "agent_id": "agt_1a2b" }

Signed evidence (contro1.request.v2)

GET /v1/requests/:id/evidence returns an evidence packet with the verified agent, the trace, tool_calls, sub_agents, retrieved_context, the approval records and an integrity signature. GET /v1/agents/:agent_id/evidence returns a signed bundle of every packet for one agent, or CSV with ?format=csv.

The signature is HMAC-SHA256 over the canonical packet, computed with your organization webhook secret, the same secret that signs callbacks. Recompute it to prove a packet has not been altered.

Signed evidence packet
{
  "evidence_packet_version": "contro1.request.v2",
  "agent": { "agent_id": "agt_1a2b", "name": "Billing Agent", "verification": "verified" },
  "trace": { "trace_id": "trc_9f2c41", "parent_trace_id": null },
  "tool_calls": [ { "name": "lookup_order", "outcome": "success" } ],
  "retrieved_context": [ { "source": "policy:refunds" } ],
  "approval_records": [ { "operator_id": "...", "decision": "approved" } ],
  "integrity": { "alg": "HMAC-SHA256", "key_id": "a1b2c3", "signed_at": "...", "signature": "..." }
}

Webhooks and signed callbacks · Audit records and cases · API endpoint index

Make the approval the gate, not a suggestion

The signed webhook is cryptographic proof of a human decision. Verify it inside the system that executes the action - not inside the agent. Any tool that must never run without human sign-off (payments, deploys, data deletion) should refuse to act without a verified approval; that way no agent, including shadow agents nobody registered, can trigger it by skipping Contro1.

  • Verify the signature and reject timestamps older than 5 minutes (replay protection; the timestamp marks callback delivery, not request creation, so long SLAs are unaffected).
  • Bind the approval to the exact action parameters via metadata / correlation_id - never treat "an approval arrived" as permission for a different action.
  • Execute each request_id exactly once (idempotency on your side).
  • When in doubt, confirm state directly with GET /v1/requests/:id using a read-only API key.

Full guardrail pattern with code: Webhooks

Frequently asked questions

How do I get the traces of all my agents into Contro1?

Give each agent its own credential, add the reporting hook for its framework (or point its OpenTelemetry exporter at Contro1), and keep one trace_id per run. Actions it runs through the Gateway are traced by Contro1 on their own. GET /v1/traces/:trace_id then returns the whole run.

Do I have to register each sub-agent?

Not if it runs inside its parent. Send the Contro1-Sub-Agent header (client.as_sub_agent in the SDKs) and Contro1 registers it under the parent the first time. Register only a sub-agent that runs as a separate program with its own credential, and link it with --parent.

Can a sub-agent do more than its parent?

No. A part runs on its parent's grants, narrowed by anything set on the part, and blocking the parent blocks all of its parts. A separately registered sub-agent has only the grants given to it.

Can I make the agent report even if the model decides not to?

Yes, by not leaving it to the model. Use a hook that runs around every tool call and fails closed. For application actions, run them through the Action Gateway, where Contro1 records the call itself. Agent Inventory flags an agent whose reports stop.

Does Contro1 record the model's reasoning?

No. It records what your code captured and what the agent chose to report, labelled apart. To keep the agent's explanation, make a reason argument part of the risky tool, so it is written when the decision is made.

How do I verify an evidence packet?

Recompute HMAC-SHA256 over the canonical packet, without the integrity field, using your organization webhook secret, and compare it to integrity.signature. It is the same routine as verifying a signed webhook.

Related resources

Connect an agent to Contro1

Choose a Contro1 connection path: five agent setup guides for production agents, Claude Code, Codex, Cursor and personal agents, plus web sign-in for Grok, ChatGPT and Claude.ai. Follow exact steps and verify access.

Contro1 Action Gateway

Choose the applications your organization approves, decide who may do what, and connect one MCP. Every person and agent gets exactly their access, and every action is recorded on their name.

Contro1 Python SDK

Install and use the framework-agnostic Contro1 Python SDK to request human approvals or input, verify signed webhooks, log autonomous actions, and fetch audit evidence.

Contro1 JavaScript and TypeScript SDK

Install and use @contro1/sdk in Node, TypeScript, Express, Fastify, or Next.js backends to create approval requests, verify webhooks, and write audit records.

Contro1 CLI

Install and use the contro1 CLI to register AI agents, create approval requests, push AI inventory, retrieve evidence and traces, and test workflows before using the SDK or API.