Skip to main content
Some tools create, delete, send, or overwrite data in ways that cannot be undone. Rather than letting an agent execute these operations silently, Toolshed provides an elicitation mechanism: the tool handler pauses mid-execution, surfaces a confirmation request to the user, and only continues after the user explicitly approves. If the user denies or cancels, the handler can abort cleanly without making any changes.

Mark a tool as destructive

The first step is declaring intent. Set destructive: true in your ToolConfig whenever the tool makes irreversible changes — creating records, sending messages, deleting data, or overwriting files:
const issuesCreate = defineTool({
  path: "github.issues.create",
  name: "Create Issue",
  description: "Create a new issue in a GitHub repository.",
  destructive: true,          // <-- declares this tool needs approval
  inputSchema: z.object({
    owner: z.string(),
    repo: z.string(),
    title: z.string(),
    body: z.string().optional(),
    labels: z.array(z.string()).optional(),
    assignees: z.array(z.string()).optional(),
  }),
  outputSchema: z.object({
    number: z.number(),
    url: z.string(),
  }),
  async handler(ctx, input) {
    // elicit() must be called before any mutation
  },
})
Destructive tools MUST call elicit() before performing any mutation. Setting destructive: true alone does not block execution — it is a signal to clients and the runtime. The actual gate is the elicit() call inside your handler. A destructive tool that skips elicit() will execute without confirmation.

Call elicit() before mutating state

Import elicit from @toolshed/sdk and call it at the start of your handler, before any write operations. Execution suspends until the user responds:
import { elicit } from "@toolshed/sdk"

async handler(ctx, input) {
  const approval = await elicit(ctx, {
    toolPath: "github.issues.create",
    message: `Create issue "${input.title}" in ${input.owner}/${input.repo}?`,
    args: input,
    type: "approval",
  })

  if (!approval.approved) {
    throw new Error("Issue creation denied by user")
  }

  // Only reaches here if the user approved
  const token = await ctx.auth.getToken("github")
  const res = await fetch(
    `https://api.github.com/repos/${input.owner}/${input.repo}/issues`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/vnd.github.v3+json",
      },
      body: JSON.stringify({
        title: input.title,
        body: input.body,
        labels: input.labels,
        assignees: input.assignees,
      }),
    }
  )
  const data = await res.json()
  return { number: data.number, url: data.html_url }
}
The elicit() function takes the PluginContext and a request object:
FieldTypeDescription
toolPathstringThe tool path being approved, e.g. "github.issues.create"
messagestringHuman-readable description shown to the user
argsRecord<string, unknown>The full input args — shown alongside the message
type"approval" | "form""approval" for yes/no; "form" for collecting additional input

Handle all three response states

elicit() returns an ElicitationResponse. Your handler must handle all possible outcomes:
const approval = await elicit(ctx, {
  toolPath: "notes.delete",
  message: `Permanently delete note "${input.id}"? This cannot be undone.`,
  args: input,
  type: "approval",
})

if (approval.approved) {
  // User confirmed — proceed with the mutation
  await db.notes.delete({ id: input.id })
  return { deleted: true }
} else {
  // User denied OR the elicitation was cancelled
  // approval.approved is false in both cases
  throw new Error("Deletion was not approved")
}
The ElicitationResponse shape:
type ElicitationResponse = {
  executionId: string
  approved: boolean        // true = approved, false = denied or cancelled
  data?: Record<string, unknown>  // populated for type: "form" responses
}
There are three ways a response arrives:
  • Approvedapproved: true. The user confirmed. Proceed with the mutation.
  • Deniedapproved: false. The user explicitly rejected the operation. Abort.
  • Cancelledapproved: false. The user closed the dialog without responding. Abort.
Both denied and cancelled set approved: false. Treat them the same way: do not mutate state.

Complete example: GitHub issue creation

This is the actual github.issues.create tool from the GitHub plugin, which demonstrates the full pattern:
import { definePlugin, defineTool } from "@toolshed/sdk"
import { z } from "zod"

const issuesCreate = defineTool({
  path: "github.issues.create",
  name: "Create Issue",
  description: "Create a new issue in a GitHub repository.",
  destructive: true,
  inputSchema: z.object({
    owner: z.string(),
    repo: z.string(),
    title: z.string(),
    body: z.string().optional(),
    labels: z.array(z.string()).optional(),
    assignees: z.array(z.string()).optional(),
  }),
  outputSchema: z.object({
    number: z.number(),
    url: z.string(),
  }),
  async handler(ctx, input) {
    const approval = await ctx.elicit({
      toolPath: "github.issues.create",
      message: `Create issue "${input.title}" in ${input.owner}/${input.repo}?`,
      args: input,
      type: "approval",
    })
    if (!approval.approved) {
      throw new Error("Issue creation denied by user")
    }

    const token = await ctx.auth.getToken("github")
    const res = await fetch(
      `https://api.github.com/repos/${input.owner}/${input.repo}/issues`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          Accept: "application/vnd.github.v3+json",
        },
        body: JSON.stringify({
          title: input.title,
          body: input.body,
          labels: input.labels,
          assignees: input.assignees,
        }),
      }
    )
    const data = await res.json()
    return { number: data.number, url: data.html_url }
  },
})
You can call ctx.elicit() directly on the context object (as shown above) or use the standalone elicit(ctx, request) function exported from @toolshed/sdk — they are equivalent. The standalone export is useful when you want to import elicitation logic separately from the handler file.

Resolve a pending elicitation via the API

When a destructive tool pauses, Toolshed records a pending elicitation with a unique executionId. Your application UI polls for pending elicitations and presents them to the user. Once the user responds, resolve the elicitation by calling:
POST /api/elicitation/:id/resolve
curl -X POST \
  https://your-toolshed-server/api/elicitation/exec_abc123/resolve \
  -H "Content-Type: application/json" \
  -d '{
    "executionId": "exec_abc123",
    "approved": true
  }'
The response is the ElicitationResponse that gets forwarded back to the suspended handler, which then unblocks and either proceeds or aborts based on approved. Once a tool path has been approved in a session, the ElicitationEngine caches that approval. Subsequent calls to the same tool within the same session pass through without pausing again — the user only confirms once per session per tool.