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-handovernpm install @the-handover/sdkDev 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.-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.
- SDK policy decides whether to involve humans at all. No API call, no audit row, zero latency.
- Server policy decides what to do after the API call arrives. The decision is recorded, an audit row is written, and the response is returned with
auto_resolved: trueandresolved_by: "policy".
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
| Exception | When it's raised |
|---|---|
DecisionDenied | Approver denied the action. .decision has the notes. |
DecisionExpired | Approver never responded and the timeout elapsed. |
DecisionTimeout | The SDK's own max_wait was exceeded while polling (distinct from server-side expiry). |
HandoverError | Any 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
Creates a new decision request and notifies the approver.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | What the agent wants to do |
approver | string | Yes | Email of the person who should decide |
context | string | No | Additional context to help the approver decide |
urgency | string | No | low | medium | high | critical. Default: medium |
timeout_minutes | number | No | Minutes before decision expires. Default: 60 |
channel | string | No | email | webhook | slack. Default: email. Slack requires Scale tier or above. |
options | string[] | No | approve, deny, modify. Default: ["approve","deny","modify"] |
callback_url | string | No* | Your server URL to POST the resolution to when the human decides. Required when channel is webhook. Optional for email and slack channels. |
response_type | object | No | Define what input the approver should provide. See Rich Response Types. Default: standard approve/deny/modify buttons. |
context_images | string[] | No | Up to 5 HTTP(S) image URLs for the approver to review inline alongside the decision. |
enforce | boolean | No | When 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
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
Programmatically resolve a pending decision. Use this when building a custom approval UI with channel: "webhook", or for automated testing.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | approve, deny, or modify |
notes | string | No | Additional notes |
response_data | object | No | Structured response data (e.g. chosen value, form fields). See Rich Response Types. |
resolved_by | string | No | Who resolved it. Defaults to approver email. |
Decision Lifecycle
Every decision follows this flow:
- pending — Created, approver notified. Waiting for response.
- approved — Approver approved the action.
- denied — Approver denied the action.
- modified — Approver requested changes (includes notes).
- scheduled — Approver chose a future time for the action to run. See Schedule response type. The decision has an
execute_attimestamp, and the SDK'sapprove()sleeps until that time before returning. - expired — No response within timeout.
- escalated — Timed out and escalated to backup approver (Pro+).
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
| Type | Required fields | response_data shape |
|---|---|---|
approve_deny | None (default behaviour) | null |
choose | choices (2+ strings) | { "chosen": "selected_value" } |
text_input | fields (1+ field objects) | { "field_name": "value", ... } |
number_input | Optional: min, max, label | { "value": 15 } |
confirm | None | null |
file_upload | Optional: accept, max_files, label | { "uploaded_files": [{ "name": "...", "url": "...", "type": "...", "size": 12345 }] } |
schedule | Optional: label, allow_immediate | null — 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:
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:
- Go to Dashboard → Settings and click Install Slack App. This redirects you to Slack's authorization page.
- 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"
}
Rate Limits
The API enforces both per-minute and monthly rate limits to ensure fair usage.
Per-Minute Limits
| Endpoint | Requests | Window |
|---|---|---|
POST /decisions | 30 | 1 minute |
GET /decisions/* | 30 | 1 minute |
GET /d/* | 30 | 1 minute |
POST /auth/login/* | 10 | 1 minute |
GET /auth/me | 30 | 1 minute |
POST /auth/api-keys | 10 | 1 hour |
Monthly Decision Limits
| Tier | Decisions / Month | Approvers |
|---|---|---|
| Free | 10 | 1 (account holder only) |
| Pro | 100 | 2 (you + 1) |
| Scale | 500 | 3 per API key |
| Enterprise | Custom | Custom |
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 */;
}
}
}
Errors
All errors return consistent JSON:
{ "error": "message", "code": "ERROR_CODE" }
| Code | Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid Authorization header |
INVALID_KEY | 401 | API key not found or inactive |
VALIDATION_ERROR | 400 | Missing or invalid request fields |
RATE_LIMIT | 429 | Monthly limit exceeded |
NOT_FOUND | 404 | Decision not found |
ALREADY_RESOLVED | 409 | Decision no longer pending |
EXPIRED | 410 | Decision timed out |
UNSUPPORTED_CHANNEL | 400 | Channel not available |