Getting Started

Overview Authentication Official SDKs Quick Start

API Reference

Create Decision Get Decision Resolve Decision

Concepts

Decision Lifecycle Rich Response Types Attachments & Context Server-Side Enforcement Webhooks & Callbacks Slack Rate Limits Errors

Integrations

LangChain CrewAI Claude & OpenAI Tool Use

Resources

Trust & Security
Reference

API documentation.

Everything you need to add human approval to your AI agents.

Authentication

All API requests require a Bearer token in the Authorization header.

Authorization: Bearer ho_your_api_key_here

API keys start with ho_ and are shown only once at creation. Store them securely.

Official SDKs

The easiest way to integrate — first-party SDKs for Python and TypeScript that handle polling, enforcement, and retries for you. Install one and you can skip most of the raw HTTP details below.

pip install the-handover
npm install @the-handover/sdk

Dev mode (no API key)

You can try the SDK before signing up. Initialise the client without an API key and from a terminal, and the SDK prints each decision inline and reads a/d/m from stdin — same return values as the real API, so your agent code stays identical. When you're ready, set HANDOVER_API_KEY and the SDK switches to real approvers (email/Slack/push) with no code changes.

from the_handover import HandoverClient

# No api_key -> dev mode (requires a terminal).
client = HandoverClient()

decision = client.approve(
    action="Send welcome email to alice@example.com",
    approver="you@example.com",
)
# Terminal prints the decision; you type a/d/m and the call returns.
// No apiKey -> dev mode (requires a terminal).
const client = new HandoverClient();

const decision = await client.approve({
  action: 'Send welcome email to alice@example.com',
  approver: 'you@example.com',
});
// Terminal prints the decision; you type a/d/m and the call resolves.
Heads up: The terminal prompt above requires an interactive TTY. In web servers, serverless runtimes, or Docker without -it, pass dev_mode=True (Python) or devMode: true (TypeScript) for silent auto-approval — see below — or set HANDOVER_API_KEY for real approvers. Sign up for a free key (10 decisions/month) to get real email/Slack/mobile-push notifications.

Dev mode for CI & tests

For CI pipelines or unit tests where you want every decision to auto-approve without prompting, pass an explicit dev_mode=True flag. No network call, no TTY required, no email sent — and your agent's call to approve() returns a real Decision with status approved.

from the_handover import HandoverClient

# Auto-approves every decision — perfect for pytest, CI, or local agent runs
# where you don't want to actually email anyone.
client = HandoverClient(dev_mode=True)

decision = client.approve(
    action="Refund $40 to test customer",
    approver="qa@example.com",
)
assert decision.status == "approved"
assert decision.response_notes == "auto-approved (dev_mode=True)"
import { HandoverClient } from '@the-handover/sdk';

// Auto-approves every decision — perfect for vitest/jest, CI, or local runs.
const client = new HandoverClient({ devMode: true });

const decision = await client.approve({
  action: 'Refund $40 to test customer',
  approver: 'qa@example.com',
});
expect(decision.status).toBe('approved');

Blocking approval

The approve() method creates a decision, polls until resolved, and raises DecisionDenied if the approver says no — so a denial actually stops your agent instead of relying on it to check a status field.

from the_handover import HandoverClient, DecisionDenied

client = HandoverClient(api_key="ho_live_...")

try:
    decision = client.approve(
        action="Send contract to Acme Corp for $48,000/year",
        approver="sarah@company.com",
        urgency="high",
    )
    # If we reach this line it was approved — proceed.
    send_contract()
except DecisionDenied as e:
    # Agent cannot proceed. e.decision has the approver's notes.
    log(f"Denied: {e.decision.response_notes}")
import { HandoverClient, DecisionDenied } from '@the-handover/sdk';

const client = new HandoverClient({ apiKey: 'ho_live_...' });

try {
  const decision = await client.approve({
    action: 'Send contract to Acme Corp for $48,000/year',
    approver: 'sarah@company.com',
    urgency: 'high',
  });
  // Reached this line — approved.
  await sendContract();
} catch (err) {
  if (err instanceof DecisionDenied) {
    log(`Denied: ${err.decision.response_notes}`);
  }
}

Approval policy (decide per-action)

Instead of asking for approval on every call, attach an ApprovalPolicy to the client. Actions that don't match any rule are auto-approved instantly; actions that do match go through the real approval flow. This lets you sprinkle client.approve() throughout your agent without hammering a human for trivial calls.

from the_handover import HandoverClient, DEFAULT_POLICY, ApprovalPolicy, AmountRule

# Use the built-in policy — covers destructive keywords, high urgency,
# and financial actions over $100.
client = HandoverClient(api_key="ho_live_...", policy=DEFAULT_POLICY)

# Or roll your own:
client = HandoverClient(
    api_key="ho_live_...",
    policy=ApprovalPolicy(
        require_for_keywords=["delete", "deploy", "charge"],
        require_for_urgency="high",
        amount_rules=[AmountRule(threshold=100.0, keywords=["payment", "transfer"])],
    ),
)

# Auto-approved — no keyword match, no amount, low urgency.
client.approve(action="Fetch user profile", approver="ops@co.com")

# Goes to a human — "delete" keyword matched.
client.approve(action="Delete 500 user records", approver="ops@co.com")

# Goes to a human — amount exceeds $100 threshold.
client.approve(action="Process payment", approver="finance@co.com", amount=250.0)
import { HandoverClient, DEFAULT_POLICY } from '@the-handover/sdk';

// Use the built-in policy.
const client = new HandoverClient({ apiKey: 'ho_live_...', policy: DEFAULT_POLICY });

// Or define your own:
const client2 = new HandoverClient({
  apiKey: 'ho_live_...',
  policy: {
    require_for_keywords: ['delete', 'deploy', 'charge'],
    require_for_urgency: 'high',
    amount_rules: [{ threshold: 100, keywords: ['payment', 'transfer'] }],
  },
});

// Auto-approved — no rule matches.
await client.approve({ action: 'Fetch user profile', approver: 'ops@co.com' });

// Goes to a human — amount exceeds $100.
await client.approve({ action: 'Process payment', approver: 'finance@co.com', amount: 250 });

Server-side policy rules (dashboard)

Server-side rules are the mirror image of the SDK policy above: manage them from the dashboard per API key, and the API auto-resolves matching decisions without emailing the approver. They're the right choice when you want a single source of truth across SDK versions and languages, or when you need auto-approvals visible in the audit log.

When both are present, the SDK evaluates first (it's in-process) and anything that reaches the API is re-evaluated server-side — so server rules are authoritative. Auto-resolved decisions count toward your monthly quota because they still create database rows, audit entries, and storage. Use SDK-side rules if you need truly zero-cost short-circuiting.

Pass amount on the request body to match amount-threshold rules:

decision = client.create(
    action="Refund order #4821",
    approver="finance@co.com",
    amount=29.99,
)
# If a server-side rule matched (e.g. amount < 50 → auto-approve),
# decision["status"] is "approved" and decision["auto_resolved"] is True.
const decision = await client.create({
  action: 'Refund order #4821',
  approver: 'finance@co.com',
  amount: 29.99,
});
// If a server-side rule matched, decision.status === 'approved'
// and decision.auto_resolved === true.

Uploading documents for the approver

If the approver needs to review a file (contract, screenshot, report), upload it to the decision. Max 5 files per decision, 10MB each. Executables and scripts are blocked.

decision = client.create(
    action="Review signed NDA before proceeding",
    approver="legal@company.com",
)
client.upload_attachment(decision["id"], "./contracts/nda-acme.pdf")

# Or poll for the result:
decision = client.poll(decision["id"])
const decision = await client.create({
  action: 'Review signed NDA before proceeding',
  approver: 'legal@company.com',
});

// In a browser / Node 20+:
const file = new File([pdfBytes], 'nda-acme.pdf', { type: 'application/pdf' });
await client.uploadAttachment(decision.id, file);

Exceptions

ExceptionWhen it's raised
DecisionDeniedApprover denied the action. .decision has the notes.
DecisionExpiredApprover never responded and the timeout elapsed.
DecisionTimeoutThe SDK's own max_wait was exceeded while polling (distinct from server-side expiry).
HandoverErrorAny other API error (4xx/5xx). Base class for the above.

Everything below works the same whether you use an SDK or raw HTTP. The raw endpoints are documented for completeness and for languages we don't ship an SDK for yet.

Quick Start

The core idea: your agent hits one endpoint, the approver gets an email or Slack message, they click a button, and that's it. You don't build any UI. You don't manage any notifications. Your agent just waits for the answer — or better yet, continues doing other things while it waits.

1. Create an API key

Sign in to the Dashboard with Google or GitHub, then click Create New Key. Copy the key immediately — it's only shown once.

2. Add your approver

In the Dashboard, go to your API key and add the email address of the person who will be approving decisions. They don't need an account — they just need an email (or a Slack account if you're using Slack).

3. Send a decision

curl -X POST https://thehandover.xyz/decisions \
  -H "Authorization: Bearer ho_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "Send contract to Acme Corp for $48,000/year",
    "context": "Customer requested enterprise plan after 3 sales calls.",
    "approver": "sarah@company.com",
    "urgency": "high"
  }'
import requests

resp = requests.post(
    "https://thehandover.xyz/decisions",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
        "action": "Send contract to Acme Corp for $48,000/year",
        "context": "Customer requested enterprise plan after 3 sales calls.",
        "approver": "sarah@company.com",
        "urgency": "high",
    },
)
decision = resp.json()
# { "id": "...", "status": "pending", "expires_at": "..." }
const res = await fetch("https://thehandover.xyz/decisions", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    action: "Send contract to Acme Corp for $48,000/year",
    context: "Customer requested enterprise plan after 3 sales calls.",
    approver: "sarah@company.com",
    urgency: "high",
  }),
});
const decision = await res.json();
// { id: "...", status: "pending", expires_at: "..." }

Sarah gets an email immediately with Approve, Deny, and Modify buttons. She clicks one, and the decision is resolved. That's the whole flow from the approver's side.

4. Get the result

There are two ways to receive Sarah's decision. Which one you use depends on how your agent is built:

Option A — Callback (recommended for real agents)

Add a callback_url to your request. Your agent fires the decision and continues doing other work immediately — it doesn't wait or check anything. The moment Sarah decides, The Handover POSTs the result to your URL. This is the right pattern when your agent has other tasks to handle, the approver might not respond for hours, or you're running multiple decisions at once.

# Add callback_url to any decision — works with email or slack channel
{
  "action": "Send contract to Acme Corp for $48,000/year",
  "approver": "sarah@company.com",
  "channel": "email",
  "callback_url": "https://your-agent.example.com/webhooks/handover"
}

# Sarah gets her email as normal. When she decides, we POST this to your callback_url:
{
  "event": "decision.resolved",
  "decision": {
    "id": "uuid",
    "status": "approved",
    "resolved_by": "sarah@company.com",
    "response_notes": null
  }
}

Your callback_url is an endpoint on your own server. The Handover will POST to it when the human decides. You handle it however you like — resume the agent workflow, update a database, send a notification, etc.

Option B — Poll (fine for simple scripts)

If your agent is a short script that only has one thing to do and can afford to sit and wait, just poll GET /decisions/:id every 5–10 seconds until the status changes. Simple, no server required. Not ideal if the approver might be away for hours — your process stays alive the whole time.

curl https://thehandover.xyz/decisions/DECISION_ID \
  -H "Authorization: Bearer ho_your_key"

# Keep calling until status != "pending":
{ "id": "uuid", "status": "approved", "resolved_at": "2026-04-02T13:15:00Z" }
import time

while True:
    resp = requests.get(
        f"https://thehandover.xyz/decisions/{decision['id']}",
        headers={"Authorization": f"Bearer {API_KEY}"},
    )
    result = resp.json()
    if result["status"] != "pending":
        break
    time.sleep(10)  # poll every 10 seconds

print(result["status"])  # "approved" | "denied" | "modified"
const poll = async (id) => {
  while (true) {
    const r = await fetch(`https://thehandover.xyz/decisions/${id}`, {
      headers: { "Authorization": `Bearer ${API_KEY}` },
    });
    const data = await r.json();
    if (data.status !== "pending") return data;
    await new Promise(res => setTimeout(res, 10000)); // poll every 10 seconds
  }
};

const result = await poll(decision.id);
// result.status -> "approved" | "denied" | "modified"

Create Decision

POST /decisions

Creates a new decision request and notifies the approver.

Request Body

FieldTypeRequiredDescription
actionstringYesWhat the agent wants to do
approverstringYesEmail of the person who should decide
contextstringNoAdditional context to help the approver decide
urgencystringNolow | medium | high | critical. Default: medium
timeout_minutesnumberNoMinutes before decision expires. Default: 60
channelstringNoemail | webhook | slack. Default: email. Slack requires Scale tier or above.
optionsstring[]Noapprove, deny, modify. Default: ["approve","deny","modify"]
callback_urlstringNo*Your server URL to POST the resolution to when the human decides. Required when channel is webhook. Optional for email and slack channels.
response_typeobjectNoDefine what input the approver should provide. See Rich Response Types. Default: standard approve/deny/modify buttons.
context_imagesstring[]NoUp to 5 HTTP(S) image URLs for the approver to review inline alongside the decision.
enforcebooleanNoWhen true, the GET response includes action_permitted: false until the decision is approved. Use for server-side enforcement — the agent checks this field before proceeding.

Response

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "expires_at": "2026-03-25T14:30:00.000Z",
  "created_at": "2026-03-25T12:30:00.000Z"
}

Get Decision

GET /decisions/:id

Returns the current status of a decision. Use this to poll for results.

Response

{
  "id": "550e8400-...",
  "action": "Send contract to Acme Corp",
  "status": "approved",
  "urgency": "high",
  "resolved_at": "2026-03-25T13:15:00.000Z",
  "resolved_by": "sarah@company.com"
}

Resolve Decision

POST /decisions/:id/resolve

Programmatically resolve a pending decision. Use this when building a custom approval UI with channel: "webhook", or for automated testing.

Request Body

FieldTypeRequiredDescription
actionstringYesapprove, deny, or modify
notesstringNoAdditional notes
response_dataobjectNoStructured response data (e.g. chosen value, form fields). See Rich Response Types.
resolved_bystringNoWho resolved it. Defaults to approver email.

Decision Lifecycle

Every decision follows this flow:

Rich Response Types

By default, approvers see Approve / Deny / Modify buttons. With response_type, you can ask for structured input instead — choices, text fields, numbers, or a simple confirmation. The approver's response is returned as structured JSON in response_data.

Choose from options

Present the approver with a list of choices. Their selection is returned in response_data.chosen.

{
  "action": "Select deployment target",
  "approver": "sarah@company.com",
  "response_type": {
    "type": "choose",
    "label": "Which environment?",
    "choices": ["staging", "production", "canary"]
  }
}
// response_data: { "chosen": "staging" }

Text input fields

Ask the approver to fill in one or more named fields. Each field is returned by name in response_data.

{
  "action": "Configure email campaign",
  "approver": "marketing@company.com",
  "response_type": {
    "type": "text_input",
    "fields": [
      { "name": "subject", "label": "Email subject line", "required": true },
      { "name": "notes", "label": "Additional notes", "placeholder": "Optional" }
    ]
  }
}
// response_data: { "subject": "Spring Sale", "notes": "Send at 9am" }

Number input

Ask for a number with optional min/max constraints. The value is returned in response_data.value.

{
  "action": "Set discount percentage",
  "approver": "finance@company.com",
  "response_type": {
    "type": "number_input",
    "label": "Discount %",
    "min": 0,
    "max": 50
  }
}
// response_data: { "value": 15 }

Simple confirmation

A single Confirm / Decline pair — no modify option. Use this for binary gates where you don't need custom input.

{
  "action": "Delete all staging data",
  "approver": "ops@company.com",
  "response_type": { "type": "confirm" }
}

File upload

Ask the approver to upload one or more files as their response — contracts, signed docs, evidence, etc. Uploaded files appear in response_data.uploaded_files as { name, url, type, size } objects.

{
  "action": "Provide signed NDA before we proceed",
  "approver": "legal@company.com",
  "response_type": {
    "type": "file_upload",
    "label": "Upload signed NDA",
    "max_files": 1,
    "accept": ["application/pdf"]
  }
}
// response_data: { "uploaded_files": [{ "name": "nda.pdf", "url": "...", "type": "application/pdf", "size": 84213 }] }

Schedule for later

Let the approver pick when the action should run. Alongside the standard buttons they see a date/time picker. If they choose a future time the decision status becomes scheduled, execute_at holds the ISO timestamp they picked, and the SDK's approve() sleeps until that moment before returning.

{
  "action": "Run nightly database vacuum",
  "approver": "ops@company.com",
  "response_type": {
    "type": "schedule",
    "label": "When should the vacuum run?",
    "allow_immediate": true
  }
}
// If they pick a future time:
// { "status": "scheduled", "execute_at": "2026-04-18T02:00:00.000Z" }

response_type reference

TypeRequired fieldsresponse_data shape
approve_denyNone (default behaviour)null
choosechoices (2+ strings){ "chosen": "selected_value" }
text_inputfields (1+ field objects){ "field_name": "value", ... }
number_inputOptional: min, max, label{ "value": 15 }
confirmNonenull
file_uploadOptional: accept, max_files, label{ "uploaded_files": [{ "name": "...", "url": "...", "type": "...", "size": 12345 }] }
scheduleOptional: label, allow_immediatenull — timestamp is on the decision as execute_at

Attachments & Context

Agents can provide visual and document context alongside a decision to help the approver make an informed choice.

Context Images

Pass up to 5 image URLs in context_images when creating a decision. The approver sees them inline — useful for screenshots, charts, or visual evidence:

{
  "action": "Deploy new landing page design",
  "approver": "designer@company.com",
  "context": "New design mockup for review",
  "context_images": [
    "https://storage.example.com/mockup-desktop.png",
    "https://storage.example.com/mockup-mobile.png"
  ]
}

Document Attachments

For local files the agent has (PDFs, spreadsheets, documents), upload them after creating the decision:

POST /decisions/:id/attachments

Send as multipart/form-data with one or more files in the file field. Maximum 5 attachments per decision, 10MB each.

Allowed file types: Images, PDFs, Word documents, Excel spreadsheets, PowerPoint, CSV, plain text, Markdown, JSON, XML. Executables and scripts are blocked.

# Python example
import requests

decision = client.create_decision(...)
requests.post(
    f"https://thehandover.xyz/decisions/{decision['id']}/attachments",
    headers={"Authorization": f"Bearer {api_key}"},
    files=[("file", open("report.pdf", "rb"))]
)

The approver sees linked documents with file icons on the decision page and in Slack.

Server-Side Enforcement

By default, the API trusts agents to respect the approver's decision. With enforce: true, the API actively blocks the agent by returning action_permitted: false until the decision is approved.

// Create with enforcement
{
  "action": "Transfer $50,000 to vendor",
  "approver": "cfo@company.com",
  "urgency": "critical",
  "enforce": true
}

// GET /decisions/:id response when pending:
{
  "status": "pending",
  "enforce": true,
  "action_permitted": false   // agent MUST NOT proceed
}

// After approval:
{
  "status": "approved",
  "enforce": true,
  "action_permitted": true    // agent may proceed
}

The SDKs use enforce by default and raise DecisionDenied exceptions when the action is not permitted, making it impossible for the agent to accidentally proceed.

Webhooks & Callbacks

Two separate things work together here: channel is how the approver gets notified, and callback_url is how your agent gets the result back. They're independent — you can use either or both.

How callback_url works

Add callback_url to any decision request. It's an endpoint on your own server. The Handover will POST the resolution there the moment the human decides — regardless of whether they used email or Slack. Nothing to configure in the dashboard; your agent passes the URL in each request.

This means you can send the approver a convenient email or Slack notification and have your agent pick up the result via callback — the best of both:

{
  "action": "Deploy v3 to production",
  "approver": "sarah@company.com",
  "channel": "email",               // Sarah gets a convenient email with buttons
  "callback_url": "https://your-agent.example.com/webhooks/handover"  // your agent gets the result here
}

Webhook channel (advanced — build your own UI)

Set channel: "webhook" if you want to handle the entire approval flow yourself — no email or Slack notification is sent to the approver. Instead, The Handover POSTs the decision details to your callback_url immediately on creation, and your app is responsible for presenting it to the approver. When they decide, your app calls POST /decisions/:id/resolve. Only use this if you're building a custom approval UI.

Resolution payload

Sent to your callback_url when any decision is resolved:

{
  "event": "decision.resolved",
  "decision": {
    "id": "uuid",
    "status": "approved",         // approved | denied | modified
    "resolved_by": "sarah@company.com",
    "response_notes": "15",
    "response_data": { "value": 15 }  // structured data from rich response types
  },
  "timestamp": "2026-03-25T13:15:00.123Z"
}

Verifying signatures

Every callback includes an X-Handover-Signature header (HMAC-SHA256 of the body, using your API key's webhook secret). Always verify it before acting:

import { createHmac } from 'crypto';

function verifyHandover(body: string, signature: string, secret: string) {
  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(body).digest('hex');
  return expected === signature;
}

Your webhook secret is auto-generated when you create an API key. Retrieve it programmatically via GET /auth/me (field: api_keys[].webhook_secret). Store it securely alongside your API key.

Retries

Failed deliveries (non-2xx response or timeout) retry up to 3 times: immediately, after 5 s, after 30 s.

Slack Integration

Send decisions directly to Slack as interactive messages. Requires Scale tier or above.

Setup

Install The Handover Slack app to your workspace in two steps:

  1. Go to Dashboard → Settings and click Install Slack App. This redirects you to Slack's authorization page.
  2. Authorize the app for your workspace. You'll be redirected back to the dashboard once complete.

The app requests these permissions: chat:write, users:read, users:read.email, im:write.

Usage

Set channel: "slack" when creating a decision. The approver will receive an interactive DM with Approve, Deny, and Modify buttons.

{
  "action": "Deploy v2.1 to production",
  "approver": "sarah@company.com",
  "channel": "slack",
  "urgency": "high"
}
Requirements: The approver's email must match their Slack profile email. The Handover app must be installed in the approver's workspace (via Dashboard → Settings).

Rate Limits

The API enforces both per-minute and monthly rate limits to ensure fair usage.

Per-Minute Limits

EndpointRequestsWindow
POST /decisions301 minute
GET /decisions/*301 minute
GET /d/*301 minute
POST /auth/login/*101 minute
GET /auth/me301 minute
POST /auth/api-keys101 hour

Monthly Decision Limits

TierDecisions / MonthApprovers
Free101 (account holder only)
Pro1002 (you + 1)
Scale5003 per API key
EnterpriseCustomCustom

Rate Limit Response

When you exceed a rate limit, the API returns 429 Too Many Requests:

{
  "error": "Rate limit exceeded",
  "code": "RATE_LIMIT"
}

The response includes a Retry-After header indicating when you can retry (in seconds). When your monthly limit is reached, you'll receive the same 429 response and will need to upgrade your plan or wait for the next billing cycle.

LangChain Integration

Both SDKs ship with a ready-made HandoverApprovalTool for LangChain. Drop it into your agent and it'll block on human approval before any high-risk action proceeds — denial raises DecisionDenied (Python) or returns a DENIED: string (TypeScript) that your agent cannot ignore.

from the_handover.integrations.langchain import HandoverApprovalTool
from langchain.agents import initialize_agent, AgentType
from langchain_openai import ChatOpenAI

approval_tool = HandoverApprovalTool(
    approver="you@company.com",
    api_key=os.environ["HANDOVER_API_KEY"],
)

agent = initialize_agent(
    tools=[approval_tool, ...],
    llm=ChatOpenAI(model="gpt-4o"),
    agent=AgentType.OPENAI_FUNCTIONS,
)
agent.run("Refund order #4821 for $180")
import { HandoverApprovalTool } from '@the-handover/sdk/integrations/langchain';
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
import { ChatOpenAI } from '@langchain/openai';

const approvalTool = new HandoverApprovalTool({
  approver: 'you@company.com',
  apiKey: process.env.HANDOVER_API_KEY!,
});

const agent = await createToolCallingAgent({
  llm: new ChatOpenAI({ model: 'gpt-4o' }),
  tools: [approvalTool, ...],
  prompt,
});
await new AgentExecutor({ agent, tools: [approvalTool, ...] }).invoke({ input: 'Refund order #4821' });

CrewAI Integration

The Python SDK exposes the same tool for CrewAI. Attach it to any agent whose tasks touch money, customer data, or production infra.

from the_handover.integrations.crewai import HandoverApprovalTool
from crewai import Agent, Task, Crew

refund_agent = Agent(
    role="Refund specialist",
    tools=[HandoverApprovalTool(approver="ops@company.com")],
    goal="Process refunds safely",
)
Crew(agents=[refund_agent], tasks=[Task(description="...", agent=refund_agent)]).kickoff()

Claude & OpenAI Tool Use

If you're calling an LLM API directly (no framework), use the SDK's tool-definition helpers. They return a schema you can drop straight into your tools array plus a dispatcher that creates the decision and blocks until resolved.

from the_handover import HandoverClient, DecisionDenied
from the_handover.integrations.openai import (
    handover_tool_definition,
    handle_handover_call,
)
from openai import OpenAI

client = OpenAI()
handover = HandoverClient(api_key=os.environ["HANDOVER_API_KEY"])

resp = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=[handover_tool_definition()],
)

for call in resp.choices[0].message.tool_calls or []:
    if call.function.name == "request_human_approval":
        try:
            result = handle_handover_call(handover, call, default_approver="you@company.com")
            # result is the resolved Decision — approved/modified/scheduled already waited
        except DecisionDenied as e:
            # Feed back to the model as a tool result; it will pick another path
            tool_result = f"DENIED: {e.reason}"
import Anthropic from '@anthropic-ai/sdk';
import { HandoverClient, DecisionDenied } from '@the-handover/sdk';
import { handoverToolDefinition, handleHandoverCall } from '@the-handover/sdk/integrations/anthropic';

const anthropic = new Anthropic();
const handover = new HandoverClient({ apiKey: process.env.HANDOVER_API_KEY! });

const resp = await anthropic.messages.create({
  model: 'claude-opus-4-7',
  max_tokens: 1024,
  tools: [handoverToolDefinition()],
  messages,
});

for (const block of resp.content) {
  if (block.type === 'tool_use' && block.name === 'request_human_approval') {
    try {
      const decision = await handleHandoverCall(handover, block, { defaultApprover: 'you@company.com' });
      // decision is approved/modified — SDK already blocked on pending + scheduled
    } catch (e) {
      if (e instanceof DecisionDenied) /* feed 'DENIED: ...' back to Claude */;
    }
  }
}
Why use the helpers? They set the tool name, schema, and description The Handover expects, and the dispatcher handles polling, scheduled-execute sleeps, and enforcement — so the model can't accidentally proceed after a denial.

Errors

All errors return consistent JSON:

{ "error": "message", "code": "ERROR_CODE" }
CodeStatusDescription
UNAUTHORIZED401Missing or invalid Authorization header
INVALID_KEY401API key not found or inactive
VALIDATION_ERROR400Missing or invalid request fields
RATE_LIMIT429Monthly limit exceeded
NOT_FOUND404Decision not found
ALREADY_RESOLVED409Decision no longer pending
EXPIRED410Decision timed out
UNSUPPORTED_CHANNEL400Channel not available