Framework guides

How to add human approvals to Mastra agents and workflows

Add Contro1 approval gates to Mastra agents and workflows so risky tool calls, workflow steps, and production actions wait for signed human decisions.

Mastra supports agents, tools, and workflows; Contro1 gives those action points role-based approvals, escalation, callbacks, and audit evidence.

Use the integration skill

Copy this skill link into your code agent to add Mastra and Contro1 to your system.

Copy skill link

Key takeaways

  • Start with one approval call before a risky Mastra tool executes.
  • Use a full production wrapper when you need routing checks, signed callbacks, follow-up logs, and evidence export.
  • Control Map previews who can approve right now by role, shift coverage, fallback reviewers, and quorum requirements.
  • Use audit records for actions that were allowed to run, and evidence packets when compliance needs the full decision trail.

What this integration does

A Mastra agent can decide to call tools, and a Mastra workflow can run known production steps. Contro1 sits immediately before the risky action. The agent asks Contro1 for a human decision, Contro1 routes the request to the right role, and the action runs only after an approved signed result.

To get started, wire up a single tool first, such as deploy, send_email, issue_refund, update_crm, grant_access, delete_data, or transfer_money. Confirm the whole flow works and is configured correctly, then move on to more tools and add Control Map, action logs, and evidence export.

Setup

There is no Mastra-specific npm package required for this first integration. The examples use direct HTTPS calls to Contro1 so you can add approvals to an existing Mastra project immediately.

If your project already uses the Contro1 TypeScript SDK, you can replace the fetch calls with the SDK client. The required runtime configuration stays the same.

.env
CENTCOM_API_KEY=cc_live_your_key
CENTCOM_BASE_URL=https://api.contro1.com/api/centcom/v1
CENTCOM_WEBHOOK_SECRET=whsec_your_secret
CENTCOM_CALLBACK_URL=https://your-app.example.com/webhooks/contro1

Short example: ask for approval

This is the smallest useful pattern. Put it at the top of a risky Mastra tool before the real business action runs.

approve.ts
async function askForApproval(runId: string, toolId: string, input: unknown) {
  const request = await fetch('https://api.contro1.com/api/centcom/v1/requests', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CENTCOM_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      type: 'approval',
      question: `Approve Mastra tool: ${toolId}?`,
      context: input,
      callback_url: 'https://your-app.example.com/webhooks/contro1',
      required_role: 'manager',
      external_request_id: `mastra:${runId}:${toolId}`,
      correlation_id: runId,
      metadata: { integration: 'mastra', tool_id: toolId },
    }),
  }).then((r) => r.json());

  return request.id;
}

Full example: production Mastra tool

A production integration normally has three parts: create the approval request, wait for a verified decision, then run the business action.

The example below uses a direct HTTP call so it works before you add a dedicated connector package. Replace waitForSignedContro1Decision with your webhook-backed decision store.

src/mastra/tools/deployRelease.ts
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';

export const deployRelease = createTool({
  id: 'deploy-release',
  description: 'Deploy a release after human approval.',
  inputSchema: z.object({ version: z.string(), environment: z.string() }),
  execute: async ({ context }) => {
    const runId = process.env.MASTRA_RUN_ID ?? crypto.randomUUID();
    const toolId = 'deploy-release';
    const externalRequestId = `mastra:${runId}:${toolId}:${context.version}`;

    const request = await fetch('https://api.contro1.com/api/centcom/v1/requests', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.CENTCOM_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        type: 'approval',
        question: `Deploy ${context.version} to ${context.environment}?`,
        context,
        callback_url: process.env.CENTCOM_CALLBACK_URL,
        required_role: 'developer',
        priority: context.environment === 'production' ? 'urgent' : 'normal',
        external_request_id: externalRequestId,
        correlation_id: runId,
        metadata: { integration: 'mastra', tool_id: toolId, version: context.version },
      }),
    }).then((r) => r.json());

    const decision = await waitForSignedContro1Decision(request.id);
    if (!decision.response?.approved) throw new Error('Deploy rejected by operator');

    const result = await deploy(context.version, context.environment);

    return { result, contro1_request_id: request.id };
  },
});

If routing fails, check who is available

Most integrations do not need Control Map in the normal approval path. If a request cannot be routed, times out unexpectedly, or your app wants to show an operational error, call Control Map to see who is currently available.

It helps answer which required roles are mapped, who is on shift now, whether fallback reviewers exist, and which warning explains the routing problem.

src/mastra/controlMap.ts
const preview = await fetch('https://api.contro1.com/api/centcom/v1/requests/control-map', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CENTCOM_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    approval_requirements: {
      required_roles: ['developer'],
      required_approvals: 1,
    },
    metadata: {
      integration: 'mastra',
      workflow_id: 'release-deploy',
      step_id: 'production-deploy',
    },
  }),
}).then((r) => r.json());

console.log(preview.satisfiable);        // can this request be routed?
console.log(preview.on_shift_capacity);  // who is currently available?
console.log(preview.fallback_reviewers); // who can receive fallback routing?
console.log(preview.warnings);           // why routing may fail

API endpoint index · Requests API

Workflow approval step

If the risky action is part of a deterministic Mastra workflow, keep the approval as its own step between prepare and execute. The workflow state should carry the run ID, request ID, reviewer comment, and final decision.

src/mastra/workflows/approvalStep.ts
const approvalRequest = {
  type: 'approval',
  question: 'Approve CRM write-back?',
  context: 'Write 240 qualified leads to production CRM.',
  callback_url: process.env.CENTCOM_CALLBACK_URL,
  required_role: 'manager',
  external_request_id: `mastra:${runId}:crm-write`,
  correlation_id: runId,
  metadata: {
    integration: 'mastra',
    workflow_id: 'lead-enrichment',
    step_id: 'crm-write',
  },
};

Mastra docs · Requests API · Webhooks

Log autonomous and post-approval actions

Every approval request already stores the human decision in Contro1. You do not need to create an audit record just to prove the approval happened.

Use POST /audit-records for actions that did not need approval, and optionally after an approved action completes if you want to record what your Mastra system actually did next.

log_allowed_action.ts
await fetch('https://api.contro1.com/api/centcom/v1/audit-records', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CENTCOM_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    action: 'mastra.search_completed',
    summary: 'Searched internal docs for refund policy',
    source: { integration: 'mastra', workflow_id: 'support-agent', run_id },
    outcome: 'success',
    severity: 'info',
    correlation_id: runId,
  }),
});
log_post_approval_action.ts
await fetch('https://api.contro1.com/api/centcom/v1/audit-records', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CENTCOM_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    action: 'mastra.crm_write_completed',
    summary: 'Wrote 240 qualified leads to production CRM after approval',
    source: { integration: 'mastra', workflow_id: 'lead-enrichment', run_id },
    outcome: 'success',
    correlation_id: runId,
    in_reply_to: { type: 'request', id: requestId },
  }),
});

Get evidence for compliance or incident review

Evidence is the exportable packet for one reviewed action. It includes the request metadata, reviewer decision, policy context, timestamps, callback delivery status, and final protocol response.

Use the request evidence endpoint when compliance asks for proof of one approval. Use the case endpoint when you need the full timeline of approvals and audit records for one Mastra run.

get_evidence.ts
const evidence = await fetch(
  `https://api.contro1.com/api/centcom/v1/requests/${requestId}/evidence`,
  { headers: { Authorization: `Bearer ${process.env.CENTCOM_API_KEY}` } },
).then((r) => r.json());
get_case.ts
const timeline = await fetch(
  `https://api.contro1.com/api/centcom/v1/cases/${runId}`,
  { headers: { Authorization: `Bearer ${process.env.CENTCOM_API_KEY}` } },
).then((r) => r.json());

Send full agent traceability

Beyond the approval call, attach identity, a run trace, the tools you invoked, and the context you retrieved. Each field is optional — add what you have. The verified identity always comes from your API key; a caller-supplied actor.agent_id is recorded as a claimed sub-agent until an admin verifies it.

  • trace_id / parent_trace_id — link one run (and sub-agent runs) into a single trace.
  • tool_calls[] — what the agent tried to do, so reviewers see the actions.
  • retrieved_context[] — the data the decision was based on (RAG provenance).
  • Then export a signed evidence packet from GET /requests/:id/evidence.
Send full traceability
// Same approval call you already make — now with full traceability.
await client.requests.create({
  request_type: "approval",
  title: "Refund $4,200 to customer 8831",
  source: { integration: "mastra" },
  actor: { agent_id: "billing-agent", agent_name: "Billing Agent" }, // claimed sub-agent
  trace_id: `trc_${runId}`,          // link every step of this run
  tool_calls: [{ name: "lookup_order", outcome: "success" }],
  retrieved_context: [{ source: "policy:refunds", uri: "kb://policy/refunds" }],
  continuation: { mode: "decision" },
});

Agent identity, traceability & signed evidence

Frequently asked questions

Should approval live in a tool or workflow step?

Put approval inside a tool when the agent chooses whether to call it. Put approval in a workflow step when the process is deterministic and the risky action is known in advance.

What should correlation_id be?

Use the Mastra run ID, workflow run ID, or another stable execution ID. Keep external_request_id scoped to the exact tool call or workflow step.

What does Control Map return?

It previews routing: whether the approval can be satisfied, which roles are mapped, who is currently available by shift or fallback, and whether quorum or separation-of-duties requirements can be met.

How do I prove what happened after approval?

Log the completed action with POST /audit-records and include in_reply_to with the Contro1 request id. This ties the human decision and the follow-up action into one case timeline.

How do I export evidence?

Use GET /requests/:id/evidence for one approved or denied action, and GET /cases/:case_id for the full timeline of approvals and audit records that share the same correlation_id.

Does this require a full SDK repo?

No. The starter repo can hold only the skill at first. The examples use direct HTTP calls so teams can adapt them to their Mastra runtime immediately.