Platform

Contro1 Action Gateway

Let Contro1 hold the provider credential and make the call, so the record of what happened is what Contro1 observed rather than what your agent reported.

The approval API answers whether something may happen. The Action Gateway answers whether it did, and lets you prove it.

Key takeaways

  • Contro1 holds the credential and makes the provider call, so the evidence is gateway_verified rather than self-reported.
  • Send is at-most-once, never exactly-once. An unknown outcome becomes a human task and is never retried automatically.
  • An explicit Idempotency-Key is required for any side-effecting Action; the SDK will not invent one for you.
  • A grant is the only thing that authorizes an Action. Scopes and policy can only narrow it.
  • A missing personal connection never falls back to a shared or organization account.
  • Organization-wide access needs two distinct administrators and is read-only.

What changes

With the approval API, your code holds the credential and executes after a human says yes. The record is your report of what you did. With the Action Gateway, Contro1 holds the credential and makes the call, so the record is what Contro1 observed. Every difference below follows from that one.

The Gateway is part of Contro1 for every organization. No plan tier, entitlement or feature flag decides whether you can use it. What decides access is an explicit credential scope and an explicit grant.

Send is at-most-once

The Gmail API has no idempotency key, so there is no way to ask the provider whether it already did something and get a reliable answer. Contro1 does not claim exactly-once for send, and neither should anything built on it.

An explicit Idempotency-Key header is required for any side-effecting Action. Contro1 will not derive one from your payload: two identical reminders an hour apart are two sends, not one, and only the caller knows that.

When an outcome is genuinely unknown, the invocation ends in execution_indeterminate and stops. That is a human task, not a transient error. Retrying it programmatically is how the second message gets sent.

terminal
curl -X POST https://api.contro1.com/api/centcom/v1/actions/invoke \
  -H "Authorization: Bearer $CONTRO1_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "action_id": "gmail.message.send",
    "input": { "to": "customer@example.com", "subject": "Your order", "body": "..." },
    "authority_mode": "agent_principal",
    "account_mode": "shared",
    "connection_id": "conn_finance"
  }'

Waiting is not retrying

Both SDKs poll. They re-read state and never re-submit, because a resubmission on a network blip would send a second message you could not tell apart from the first. A timeout raises rather than returning, and says the invocation is still live: read it again, do not send another.

send.py
from centcom import CentcomClient, needs_human_resolution
import os, uuid

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

result = client.actions.invoke(
    action_id="gmail.message.send",
    input={"to": "customer@example.com", "subject": "Your order", "body": "..."},
    authority_mode="agent_principal",
    account_mode="shared",
    connection_id="conn_finance",
    idempotency_key=str(uuid.uuid4()),
)

settled = client.actions.wait_for_invocation(result["invocation"]["invocation_id"])
if needs_human_resolution(settled):
    escalate_to_a_person(settled)  # Do NOT retry.
send.ts
import { CentcomClient, ActionsApi, needsHumanResolution } from "@contro1/sdk";

const actions = new ActionsApi(new CentcomClient({ apiKey: process.env.CONTRO1_API_KEY! }));

const { invocation } = await actions.invoke({
  action_id: "gmail.message.send",
  input: { to: "customer@example.com", subject: "Your order", body: "..." },
  authority_mode: "agent_principal",
  account_mode: "shared",
  connection_id: "conn_finance",
  idempotency_key: crypto.randomUUID(),
});

const settled = await actions.waitForInvocation(invocation.invocation_id);
if (needsHumanResolution(settled)) {
  // Do NOT retry. Escalate to a person.
}

Who is allowed

Every check must pass, and none can be skipped by holding a higher plan. An explicit scope on the credential; a matching grant, which is the only thing that grants; the authority ceiling, which can only narrow what a grant allows; a compatible connection; then identity, boundary and policy.

A legacy API key with no stored scopes is refused with SCOPE_UPGRADE_REQUIRED rather than being waved through. That key works elsewhere, and this surface is the deliberate exception.

A delegated call must be made with the acting person's own credential. Contro1 refuses a delegated call made on somebody else's behalf, because nothing in the request proves the caller may act for them.

Ownership is not permission. Every agent has an accountable owner, and that owner's permissions do not flow to the agent, nor can the agent use the owner's personal connection.

Accounts and boundaries

A personal connection reaches one employee's account. A shared connection reaches a team mailbox and is read-only by default; a write or send scope needs an explicit, recorded confirmation. An organization connection reaches a delegated domain and is read-only in this release.

A missing personal connection never falls back to a shared or organization one. It returns user_auth_required, always. Silently using a team mailbox because a personal one was not connected is exactly what this rule prevents.

An organization-wide grant needs a permission that manage_org does not imply, is read-only, and requires two distinct administrators: it is created inert and authorizes nothing until a second administrator, who independently holds the permission and did not create it, confirms it.

Integrity verification

Every state transition stores a hash chained to the previous one, and a verifier checks the chain. That detects partial edits, replication faults and application bugs.

It is not tamper-proof, and Contro1 does not describe it that way. Someone with full database write access can rewrite the events and recompute the chain. Real tamper-evidence requires anchoring the chain head outside the database, which is Phase 2 work. Until then this is integrity verification and nothing stronger.

What is never recorded

Canonical input, message bodies, provider responses, credentials, tokens and ciphertext never appear in an audit record, an error message, a log line or the Activity feed. What is recorded is the invocation, the Action, the app, the account mode, the connection, the agent, the authority mode, the target and the outcome.

Activity renders a gateway-verified execution and a client-reported claim distinctly. If you are citing evidence, that distinction is the evidence.

Frequently asked questions

Is a sent email guaranteed to be sent exactly once?

No, and Contro1 does not claim it is. The Gmail API has no idempotency key, so send is at-most-once. When an outcome is genuinely unknown the invocation ends in execution_indeterminate and stops: a person checks the provider and decides. Automatic retry is what would produce a duplicate.

Does the Action Gateway require a particular plan?

No. It is part of Contro1 for every organization. Access is decided by an explicit credential scope and an explicit grant, and no plan tier, entitlement or feature flag participates in that decision.

Is the audit trail tamper-proof?

It is integrity-verified, which is a weaker and more honest claim. Each state transition is hash-chained and verified, which detects partial edits, replication faults and application bugs. Someone with full database write access could rewrite the events and recompute the chain, so real tamper-evidence requires anchoring the chain head outside the database. That is planned, not shipped.

Can an agent use the connected account of the person who owns it?

No. Ownership answers who is accountable, not what an agent may do. An owner permissions do not flow to the agent they own, and the agent cannot use the owner personal connection.

What happens if an employee has not connected their account?

The invocation returns user_auth_required. Contro1 never falls back to a shared or organization account, because silently sending from a team mailbox is the outcome that rule exists to prevent.

Related resources

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.