Core API

Quickstart: add approvals to an AI agent in minutes

Create your first Contro1 request, route it to the right operator, and return a signed callback to your agent.

Use this quickstart when you want to add a human approval step to an existing agent without redesigning the whole workflow.

Key takeaways

  • A Contro1 request is a single POST to https://api.contro1.com/api/centcom/v1/requests with a question, context, and callback URL.
  • The human answer arrives as a signed webhook you verify before resuming the agent.
  • Idempotency keys keep retries safe; required_role keeps the decision with the right human.
  • If the agent waits synchronously, set its wait timeout longer than the request SLA plus a small callback buffer.
  • Optional policy_context and approval_comment_required fields let any custom policy layer preserve why review was required.
  • Add correlation_id when this request belongs to a larger agent run or case timeline.

What you need

  • A Contro1 API key (get one in Settings -> APIs & Webhooks)
  • A callback URL your server controls - Contro1 will POST the signed decision there
  • A clear question, supporting context, and the role that should answer

How the approval loop works

Your agent creates a request when it reaches a risky or policy-sensitive action. CENTCOM routes it to the right human based on required_role, shift coverage, and priority.

If the agent polls or blocks while waiting, configure its local timeout to exceed the Contro1 SLA. For example, a 10 minute SLA should use a wait timeout above 10 minutes so the agent does not auto-cancel or detach before Contro1 can return timed_out or escalate.

The request moves through these states:

  • pending - created, not yet claimed by an operator
  • in_review - an operator opened and is reading the request
  • approved - operator approved; the signed callback is on its way to your server
  • rejected - operator rejected; your agent should halt or route differently
  • timed_out - no one answered within the SLA window; treat as rejected and fail closed
  • cancelled - you called DELETE on the request before it was answered

Create your first request

create_request.sh
curl -X POST https://api.contro1.com/api/centcom/v1/requests \
  -H "Authorization: Bearer cc_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "approval",
    "question": "Approve refund over policy limit?",
    "context": "Customer requests a $2,400 refund after a shipping failure.",
    "callback_url": "https://your-app.com/webhooks/contro1",
    "required_role": "manager",
    "priority": "urgent",
    "external_request_id": "refund-8842:approval",
    "correlation_id": "case-refund-8842",
    "metadata": { "customer_id": "c-8821" }
  }'
create_request.py
from centcom import CentcomClient

client = CentcomClient(api_key="cc_live_your_key")

request = client.create_request(
    type="approval",
    question="Approve refund over policy limit?",
    context="Customer requests a $2,400 refund after a shipping failure.",
    callback_url="https://your-app.com/webhooks/contro1",
    required_role="manager",
    priority="urgent",
    external_request_id="refund-8842:approval",
    correlation_id="case-refund-8842",
    metadata={"customer_id": "c-8821"},
)

print(request["id"], request["status"])  # req_abc123  pending
create_request.ts
import { CentcomClient } from '@contro1/sdk';

const client = new CentcomClient({ apiKey: 'cc_live_your_key' });

const request = await client.createRequest({
  type: 'approval',
  question: 'Approve refund over policy limit?',
  context: 'Customer requests a $2,400 refund after a shipping failure.',
  callback_url: 'https://your-app.com/webhooks/contro1',
  required_role: 'manager',
  priority: 'urgent',
  external_request_id: 'refund-8842:approval',
  correlation_id: 'case-refund-8842',
  metadata: { customer_id: 'c-8821' },
});

console.log(request.id, request.status); // req_abc123  pending

Where that context line came from

The context in the example ("Customer requests a $2,400 refund after a shipping failure") is not something the reviewer should hope the agent volunteers. Your gate builds it from three sources: the exact tool input your code intercepted (machine-observed fact), the message or event that triggered the run, and the agent's own justification - which you get reliably by making reason a required parameter of the risky tool, so the model produces it at decision time.

Keep the two apart: facts your code observed versus text the model wrote. Agent-written justification helps the reviewer decide, but it must never change routing or risk_level, because a prompt-injected agent writes very persuasive reasons. See the Requests API reference for the full context pattern with provenance labels.

Reviewer context in the Requests API

What the response looks like

The API returns immediately with a pending request. Store the request id and wait for the signed callback - do not poll.

response.json
{
  "id": "req_abc123",
  "status": "pending",
  "state": "queued",
  "type": "approval",
  "question": "Approve refund over policy limit?",
  "required_role": "manager",
  "priority": "urgent",
  "external_request_id": "refund-8842:approval",
  "correlation_id": "case-refund-8842",
  "routing_source": "api_key_default",
  "request_type": "review",
  "continuation_mode": "decision",
  "created_at": "2025-01-15T10:30:00Z",
  "expires_at": "2025-01-15T11:30:00Z",
  "metadata": { "case_id": "refund-8842" }
}

Handle the callback

When an operator responds, Contro1 sends a signed POST to your callback_url. Always verify the signature and timestamp before applying the decision.

New handlers can usually read top-level status plus response. protocol_response and structured_response are included for protocol adapters and compatibility.

webhook.ts
import express from 'express';
import { verifyWebhook } from '@contro1/sdk';

const app = express();
app.use(express.raw({ type: 'application/json' }));

app.post('/webhooks/contro1', async (req, res) => {
  const signature = req.headers['x-centcom-signature'] as string;
  const timestamp = req.headers['x-centcom-timestamp'] as string;
  const secret = process.env.CONTRO1_WEBHOOK_SECRET!;

  if (!verifyWebhook(req.body, signature, timestamp, secret)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString());
  const { request_id, status, response } = event;

  switch (status) {
    case 'approved':
      await resumeAgent(request_id, response);
      break;
    case 'rejected':
    case 'denied':
      await haltAgent(request_id, response?.comment);
      break;
    default:
      await failClosed(request_id);
  }

  res.sendStatus(200);
});
webhook.py
import os
from flask import Flask, request, abort
from centcom import verify_webhook

app = Flask(__name__)

@app.route('/webhooks/contro1', methods=['POST'])
def handle_callback():
    signature = request.headers.get('X-CentCom-Signature', '')
    timestamp = request.headers.get('X-CentCom-Timestamp', '')
    secret = os.environ['CONTRO1_WEBHOOK_SECRET']

    if not verify_webhook(request.data, signature, timestamp, secret):
        abort(401)

    event = request.get_json(force=True)
    status = event['status']
    request_id = event['request_id']

    if status == 'approved':
        resume_agent(request_id, event['response'])
    elif status in ('rejected', 'denied'):
        halt_agent(request_id, event['response'].get('comment'))
    else:
        fail_closed(request_id)

    return '', 200

Map all outcomes

Your agent must handle every terminal state. The default should always be fail closed.

  • approved - operator approved the action; resume with the decision payload attached
  • rejected - operator rejected; halt and log the comment if provided
  • timed_out - no one answered within the SLA; treat as rejected and optionally route to a fallback approver
  • cancelled - you called DELETE before anyone answered; treat as rejected

What to do next

  • Read the Webhooks doc to understand signature verification in detail
  • Add an idempotency key (external_request_id) so retries never create duplicate requests
  • Add policy_context when your own rules, risk engine, or policy service caused the approval
  • Set approval_comment_required when a low or medium risk workflow still needs reviewer justification
  • Use correlation_id and audit records when the same agent run has both human decisions and autonomous follow-up actions
  • Use the Runtime API guide when you want the developer story from first call to signed evidence
  • Use the API endpoint index when you want the short list of every available call
  • Read the Requests API reference to see all fields, priorities, and cancellation

Runtime API guide · API endpoint index · Webhooks: signature verification · Requests API reference · Audit records and cases · Authentication and API keys

Go further: full agent traceability

Once approvals work, add identity and traceability one rung at a time — you do not have to do it all at once. Bind your API key to an agent (every request is then attributed automatically), pass a trace_id to link a whole run, attach tool_calls[] and retrieved_context[] so reviewers see what the agent did and why, set least-agency scopes to bound its authority, and export an HMAC-signed evidence packet for any decision.

  • Identity — bind a key to an agent; verified vs claimed sub-agents.
  • Trace — trace_id (+ parent_trace_id) links every step of a run.
  • Tools & grounding — tool_calls[], retrieved_context[].
  • Least agency — per-agent action/department/risk scopes, warn or strict.
  • Evidence — GET /requests/:id/evidence returns a signed contro1.request.v2 packet.

Agent identity, traceability & signed evidence

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

When should an agent create a Contro1 request?

Create a request when the action is high-impact, irreversible, financially sensitive, or policy-sensitive.

Can I use Contro1 without changing my whole stack?

Yes. The normal starting point is a single API call plus a callback handler around the risky action.

What happens if the operator does not answer in time?

The request expires with a timed_out status. Your workflow should fail closed by default and optionally route to a fallback approver.

Do I need a dedicated SDK?

No. Any HTTP client works. We publish framework-specific helpers (LangGraph, OpenAI Agents, CrewAI, n8n, Claude managed agents) so you do not have to glue the HTTP calls yourself.

Related resources

A Complete Guide to the API

A practical walkthrough of the Contro1 runtime API: which endpoint an agent calls, when to call it, what to send, and how approvals, audit records, traces, and evidence fit together.

API endpoint index

A compact map of the public Contro1 runtime API: request creation, Control Map preview, audit records, and thread timelines.