NewIntroducing Integrations
All examples
Mastra
Updated
9 min read
by Anlyon Team

Mastra tool approval: require a human decision before a tool writes to GitHub

Mastra's requireApproval pauses a tool call in your app until your code approves it. This example routes a Mastra tool through an Anlyon action instead: an approver reviews the exact file change, Anlyon holds the GitHub token, a shared limit caps the day's writes, and the commit comes back with a graded receipt.

mastratypescriptapprovalsgithubtool callinghuman in the loop

To require approval before a Mastra tool runs, set requireApproval: true on the tool and approve the paused call from your code. That pauses the call inside your Mastra app. If the decision has to sit outside the app, with the credential, have the tool invoke an Anlyon action that requires approval. Anlyon holds the request until an approver decides, then makes the call itself with a token the agent never sees.

This example gives a Mastra agent the ability to edit Markdown files in a GitHub repository. Four things hold without relying on the model:

  • Every change waits for an approver, who sees the file path, the full new contents and the commit message.
  • The agent never holds the GitHub token. It is in the Anlyon vault and attached at dispatch.
  • A shared limit caps the day's writes. Every agent, session and key in the environment draws from the same allowance, reserved before dispatch.
  • The write is pinned to what was reviewed. If the file changed at GitHub after review, GitHub refuses the write on the reviewed blob SHA.

Written against @mastra/core 1.78 and @anlyonhq/sdk 2.3.

TL;DR

Mastra requireApprovalAnlyon action with approval
Where the run waitsA snapshot in your Mastra storageNothing waits. The request waits in Anlyon
Who decidesYour code calls approveToolCall()An approver in your workspace, from the console or the API
Where the GitHub token livesWherever your tool reads itThe Anlyon vault, attached at dispatch
What runs after approvalYour tool's execute, in your processThe exact request that was reviewed
Limit across agents and sessionsNot part of the flagA shared count of writes, reserved before dispatch
What you get backYour tool's return valueAn invocation with a receipt grade

How does Mastra's own tool approval work?

As of 2026-10-18, Mastra's agent approval docs describe two pre-execution flags. requireApproval: true on createTool() pauses calls to that tool. requireToolApproval on stream() or generate() pauses every tool call in the request, and it also accepts a function that decides per call. Either one pauses the call before execute runs:

import { Agent } from '@mastra/core/agent';
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';

const deleteRecord = createTool({
  id: 'delete-record',
  description: 'Delete a record by id',
  inputSchema: z.object({ id: z.string() }),
  requireApproval: true,
  execute: async ({ id }) => {
    await db.delete(id);
    return { deleted: true };
  },
});

const opsAgent = new Agent({
  id: 'ops-agent',
  name: 'Ops agent',
  instructions: 'You clean up records when asked.',
  model: 'openai/gpt-5-mini',
  tools: { deleteRecord },
});

const stream = await opsAgent.stream('Delete record abc-123');

for await (const chunk of stream.fullStream) {
  if (chunk.type === 'tool-call-approval') {
    console.log(chunk.payload.toolName, chunk.payload.args);
    const approved = await opsAgent.approveToolCall({ runId: stream.runId });
    for await (const text of approved.textStream) process.stdout.write(text);
  }
}

The paused run is held as a snapshot, so Mastra needs a storage provider or the approval fails with "snapshot not found". declineToolCall() takes an optional reason that goes back to the model in place of the tool result.

This is a good fit for asking the user in a chat UI before the agent acts. As of 2026-10-18 it has three properties worth knowing before a tool writes to production:

  • The decision is whatever your code passes. approveToolCall() is a method on your agent. Who is allowed to call it, and how they are authenticated, is up to your app.
  • The credential stays where it was. After approval, execute runs in your process with whatever token it reads.
  • Binding the approval to the arguments is your code. Mastra's own docs show how: fingerprint toolName and args when the approval is shown, and check it in a beforeToolCall hook before the tool runs. The docs recommend durable storage for the fingerprints in production.

How does the Anlyon version fit together?

Mastra agent (holds an Anlyon key: invoke, read)
  -> tool "update-docs-file"
    -> anlyon.actions.invoke("update-docs-file", input, { operationKey })
       returns at once: pending approval
         ... an approver reviews path, contents and message in the Anlyon console ...
    -> on approval, Anlyon reserves one write against the daily limit,
       attaches the vaulted GitHub token and writes with the reviewed blob SHA
    -> Anlyon reads the commit back from GitHub and grades the receipt

1. One-time setup, with an operator key

Run this from a deploy script, not from the agent. It stores the token, defines the action on the github.file_update adapter, and sets a daily limit.

// setup.ts
import { Client } from '@anlyonhq/sdk';

const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

await ops.secrets.put('GITHUB_DOCS_TOKEN', {
  value: process.env.GITHUB_TOKEN!,
  allowedHosts: ['api.github.com'],
});

await ops.actions.create({
  name: 'update-docs-file',
  description: 'Replace the contents of one existing file under docs/ on the agent-docs branch.',
  requiresApproval: true,
  adapter: {
    type: 'github.file_update',
    config: { owner: 'acme', repo: 'handbook', branch: 'agent-docs', pathPrefix: 'docs/' },
    credentialSecret: 'GITHUB_DOCS_TOKEN',
  },
});

// 20 file updates a day, across every agent, session and key in this environment.
await ops.impactLimits.create({
  name: 'docs-writes-per-day',
  dimension: 'resource_mutations',
  period: 'day',
  amount: 20,
  actionName: 'update-docs-file',
});

The adapter changes one existing UTF-8 text file per write, up to 64 KB. It refuses the repository's default branch unless the config sets allowDefaultBranch: true, so the agent's edits land on agent-docs and reach main through your normal pull request. Creating a limit needs impact-limits:write, which no key gets by default. Give it to the operator key, not to the agent's.

2. The tool

The tool's job is to invoke the action and tell the model, in plain words, what happened. It does not touch GitHub.

// tools/update-docs-file.ts
import { createTool } from '@mastra/core/tools';
import { Client } from '@anlyonhq/sdk';
import { z } from 'zod';

const anlyon = new Client({ apiKey: process.env.ANLYON_AGENT_KEY! });

// Fail at startup if the agent's key can do more than ask and read.
await anlyon.auth.requireScopes(['actions:invoke', 'actions:read'], {
  forbidden: ['actions:write', 'secrets:write', 'approvals:decide'],
});

export const updateDocsFile = createTool({
  id: 'update-docs-file',
  description:
    'Replace the full contents of one existing Markdown file under docs/. Every change is reviewed by an approver before it is committed.',
  inputSchema: z.object({
    changeId: z.string().describe('The change request id. One write per change request.'),
    path: z.string().describe('Path of an existing file, starting with docs/.'),
    content: z.string().describe('The complete new contents of the file.'),
    message: z.string().describe('A one-line commit message.'),
  }),
  execute: async ({ changeId, path, content, message }) => {
    const { data } = await anlyon.actions.invoke(
      'update-docs-file',
      { path, content, message },
      { operationKey: `docs:${changeId}` },
    );
    const invocation = data!;

    if (invocation.pendingApproval) {
      return {
        status: 'queued_for_review',
        invocationId: invocation.id,
        message:
          'The change is waiting for an approver. If approved, it is committed exactly as written. Do not submit it again.',
      };
    }
    return {
      status: invocation.status,
      grade: invocation.grade ?? null,
      invocationId: invocation.id,
      message: invocation.decision?.explanation ?? invocation.error ?? 'Handled.',
    };
  },
});

The operation key ties the write to the change request. A model that calls the tool twice for the same change gets the existing effect back, not a second review or a second commit. A different request under the same key is refused.

3. The agent

// agents/docs-agent.ts
import { Agent } from '@mastra/core/agent';
import { updateDocsFile } from '../tools/update-docs-file';

export const docsAgent = new Agent({
  id: 'docs-agent',
  name: 'Docs agent',
  instructions:
    'You keep the handbook accurate. When a change request arrives, edit the one file it names with updateDocsFile. Report the status exactly as the tool returns it.',
  model: 'openai/gpt-5-mini',
  tools: { updateDocsFile },
});

const output = await docsAgent.generate(
  'Change request CR-311: the on-call rotation now starts on Mondays. Update docs/on-call.md.',
);
console.log(output.text);

The call returns as soon as the change is queued. The approver sees the path, the full new contents and the commit message in Approvals in the Anlyon console. On approval, Anlyon writes the file. On denial or expiry, nothing is sent.

How do you find out what happened after approval?

Read the same invocation by id, from a job, a webhook handler or the next agent turn:

const { data: invocation } = await anlyon.actions.invocation(invocationId);
console.log(invocation!.status, invocation!.grade);

confirmed means Anlyon found the commit on agent-docs that this write created, carrying Anlyon-Effect: <effect id> in its message. failed covers the case the reviewed blob SHA exists for: if someone edited the file at GitHub after review, GitHub refuses the write with a 409, the receipt grades failed, and the newer content is not overwritten. denied means the approver said no. refused means Anlyon stopped it before dispatch, for example when the day's 20 writes are used up or the approval expired. A subscription can also receive action.succeeded and action.failed events. See Events and webhooks.

Should you keep Mastra's requireApproval as well?

Use both when they answer different questions. requireApproval is a good way to ask the user in the conversation "should I make this edit?". The Anlyon approval is the decision on the write itself: bound to the exact request, made by an approver who is not the key that asked, and enforced on Anlyon's side of the call. That holds while no other copy of the GitHub token is in your app. Turning both on for the same tool means two prompts per change, so pick the one that matches the risk.

What this does not cover

Anlyon governs the actions routed through its hosted executor. A Mastra tool that calls the GitHub API with its own token is not governed by it. The limit is shared within one environment, not across environments. The adapter changes existing files only: creating or deleting files and binary files are out of scope.

Free during Early Beta Access, no credit card. Start building →

Frequently asked questions

How do I add tool approval to a Mastra agent?

As of 2026-10-18, set requireApproval: true on createTool(), or pass requireToolApproval to stream() or generate(). The stream emits a tool-call-approval chunk, and your code calls agent.approveToolCall({ runId }) or agent.declineToolCall({ runId }). With generate(), the call returns with finishReason 'suspended' and you call approveToolCallGenerate. Mastra needs a storage provider for the snapshot that holds the paused run.

Why does the Anlyon-backed tool return before anyone approves?

A review of a file change can take hours. The tool reports that the change is queued for review and returns its invocation id, so no agent run is held open. When an approver decides, Anlyon commits the exact change they reviewed. The agent does not have to be running.

What happens if the file changes on GitHub after the change was reviewed?

The github.file_update adapter sends the reviewed blob SHA with the write. If the file changed at GitHub since review, GitHub refuses the write, the receipt grades failed, and the newer content is not overwritten.

Continue with a related task

Free tier, no credit card. One command if you use Claude or Cursor.

$ claude mcp add anlyon -- npx -y @anlyonhq/mcp-server