Agent API — Virtual Assistant Delegation
Delegate tasks from your AI agent to a real human virtual assistant. This API enables seamless AI agent handoff for human-in-the-loop tasks that LLMs cannot complete autonomously. Category: humans working for the AI · HITL ranking.
https://taskbullet.comRemote MCP: https://taskbullet.com/api/agent-mcpAuth: x-api-key: tb_live_…Rate limit: 60 req / 60s / key on this instanceOpenAPI (Swagger) Spec →MCP Server (npm) →MCP Server (GitHub) →Public site MCP →Quickstart
Issue an API key from your account settings and send your first task in under a minute.
curl -X POST https://taskbullet.com/api/v1/delegate \
-H "x-api-key: tb_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"task": "Research and compile a list of the top 10 AI newsletters with subscriber counts",
"context": "We are preparing a partnership outreach deck for Q3.",
"priority": "normal",
"webhookUrl": "https://your-app.com/hooks/taskbullet"
}'// Response
{
"success": true,
"taskId": "atask_a3f9c2d1e4b5a6f7",
"status": "awaiting_customer_start",
"message": "NOT started until you click Start. https://taskbullet.com/dashboard?tab=work&startTask=PENDING_TASK_ID"
}Remote MCP (Grok Bot / Cursor / OpenClaw)
Cloud agents cannot run npx @taskbullet/mcp-server. Use the hosted streamable-HTTP MCP instead. This is a different server from the public site MCP— it requires a key and only exposes VA handoff tools.
URL: https://taskbullet.com/api/agent-mcp
Auth: x-api-key: tb_live_… (Authorization: Bearer tb_live_… also accepted)
Tools:
get_balance → GET /api/v1/balance
delegate_task → POST /api/v1/delegate
list_tasks → GET /api/v1/tasks
get_task → GET /api/v1/tasks/{taskId}
confirm_task → POST /api/v1/tasks/{taskId}/confirm
cancel_task → POST /api/v1/tasks/{taskId}/cancel
reopen_task → POST /api/v1/tasks/{taskId}/reopen
place_call → POST /api/v1/calls
validate_call → POST /api/v1/calls/validate
get_call → GET /api/v1/calls/{id}
update_call → PATCH /api/v1/calls/{id}
cancel_call → POST /api/v1/calls/{id}/cancel
get_transcript → GET /api/v1/calls/{id}/transcript.txt
get_call_events — MCP tool. No REST route.
request_action → POST /api/v1/actions
validate_action → POST /api/v1/actions/validate
get_action → GET /api/v1/actions/{id}
update_action → PATCH /api/v1/actions/{id}
approve_action → POST /api/v1/actions/{id}/approve
cancel_action → POST /api/v1/actions/{id}/cancel
Call get_balance before proposing spend. When you cannot finish (2FA, captcha,
physical world, subjective QA, long manual work), confirm with the user then
call delegate_task. That files a brief and waits for the customer to click Start.
It does not dial. A human phone call is place_call, not delegate_task. place_call dials.
An email a human sends is request_action. It holds a flat wallet fee and a one-time delegated credential. The client approves the final text before send.
Poll get_task until completed, failed, or cancelled, or pass webhookUrl.
When get_task shows awaitingCustomerConfirm, ask the human, then confirm_task,
cancel_task, or reopen_task. Customer cancel is cancelled and does not send the completion webhook.
Never put passwords, OTP, recovery keys, cookies, or API keys in the payload.
Never invent VA results.Grok Bot
Settings → Plugins → add a custom MCP (or TaskBullet when listed in Marketplace). Server URL https://taskbullet.com/api/agent-mcp. Paste the tb_live_ key ( plugin variable TASKBULLET_API_KEY). This is API-key auth only — not OAuth. If an OAuth connect card appears, dismiss it and paste the key; tools already work with the header. Call get_balance then ask the user before delegate_task (it uses real hours) unless they already said to send it. Same URL works on grok.com/connectors → New Connector → Custom.
OpenClaw
Other lobsters install the skill with openclaw skills install @griffmurder/taskbullet. The lobster cannot sign up or mint a key. It tells the human:
- No account → start free (10 hours)
/get-started?source=openclaw - Issue
tb_live_…at /dashboard/settings/api-keys (shown once). Paste into Gateway envTASKBULLET_API_KEYplus this Agent MCP. Never put the key in chat. - Then
get_balance/delegate_task. Hours deduct when work is tracked (nearest 15 minutes). Keyed account with no hours (402) → buy hours, not another trial. Missing Basecamp queues the task — no 409.
Grok Build / Cursor config
[mcp_servers.taskbullet_agent]
url = "https://taskbullet.com/api/agent-mcp"
headers = { "x-api-key" = "tb_live_YOUR_KEY" }
enabled = true{
"mcpServers": {
"taskbullet-agent": {
"url": "https://taskbullet.com/api/agent-mcp",
"headers": { "x-api-key": "tb_live_YOUR_KEY" }
}
}
}Issue a key at /dashboard/settings/api-keys. Local stdio (npx @taskbullet/mcp-server) remains available for one laptop; it is not the Grok Bot path.
Authentication
All /api/v1/ requests must include your API key in the x-api-key header. Keys begin with tb_live_ and are issued from your settings page.
process.env) immediately.// All v1 endpoints
x-api-key: tb_live_<64 hex chars>/api/v1/delegate
File a brief for a TaskBullet VA. Send an Idempotency-Key header. The same account and key returns the original task. The brief stays off the board until you start it on Work. After you start it, the dedicated VA already stored on the account is the assignee. If none is stored, the account manager staffs it.
Request body
| Field | Type | Description |
|---|---|---|
taskrequired | string | What you want done. Max 5,000 chars. Used as the Basecamp todo title (first 240 chars). |
context | string | Additional background for the VA (max 8,000 chars). Never include passwords, OTP codes, recovery keys, cookies, or API keys — those are rejected. |
priority | "low" | "normal" | "high" | "urgent" | Defaults to "normal". |
dueDate | string | Free-form due date hint (e.g. "EOD Friday"). Appended to task description. |
webhookUrl | string | Public HTTPS URL. TaskBullet POSTs when you close the job (client_complete). A VA checkbox does not send this webhook. A customer cancel does not send it. Localhost and private IPs are rejected. |
sourceAgent | string | Name of the calling agent for audit trails (e.g. "claude-3-opus"). |
maxHours | number | Spend cap in hours. Defaults to 0.5. Max 8. The VA/AM stop and ask before exceeding. Actual tracked time is billed, not this cap. |
Response 200 OK
{
"success": true,
"taskId": "atask_a3f9c2d1e4b5a6f7", // stable ID — use to poll status
"status": "awaiting_customer_start",
"message": "NOT started until you click Start. https://taskbullet.com/dashboard?tab=work&startTask=PENDING_TASK_ID"
}"awaiting_customer_start" means the task is NOT started until the customer clicks Start on Work. It is only returned by this endpoint and will not appear in subsequent polls. Poll status stays "pending" until the task is on the board.
402 is only a live Ops read that is not above zero. It includes hoursRemaining and addHoursUrl so the agent can send the client to add hours without guessing. A cap that does not fit open caps is 422 with hoursRemaining and capRoom. Hours remain. Lower maxHours to capRoom or wait. That response has no addHoursUrl.
/api/v1/balance
Remaining hours and cap room for this API key. Call before delegate_task. Returns only hour fields — no other account data. canDelegate false while hoursRemaining is above zero means lower maxHours to capRoom or wait. It is not a buy-hours signal. capRoom is omitted when Ops did not return a live figure.
{
"hoursRemaining": 8.5,
"canDelegate": true,
"addHoursUrl": "https://taskbullet.com/packages-pricing",
"hoursUnavailable": false,
"capRoom": 8.5
}/api/v1/tasks
List delegated tasks for your account. Optionally filter by status.
Query params
| Param | Values | Description |
|---|---|---|
status | "pending" | "in_progress" | "completed" | "failed" | "cancelled" | "all" | Defaults to "all". Returns up to 50 tasks. |
// GET /api/v1/tasks?status=completed
{
"tasks": [
{
"taskId": "atask_a3f9c2d1e4b5a6f7",
"title": "Call the vendor about the invoice",
"status": "completed",
"priority": "normal",
"createdAt": "2026-05-28T14:00:00.000Z",
"completedAt": "2026-05-29T09:15:00.000Z",
"result": "Vendor confirmed payment posted today."
}
]
}/api/v1/tasks/:ref
Poll the status of a specific task by its taskId returned from /delegate. Stop when status is completed, failed, or cancelled. A customer cancel is cancelled. It does not send the completion webhook.
// GET /api/v1/tasks/atask_a3f9c2d1e4b5a6f7
{
"taskId": "atask_a3f9c2d1e4b5a6f7",
"status": "completed", // pending | in_progress | completed | failed | cancelled
"priority": "normal",
"createdAt": "2026-05-28T14:00:00.000Z",
"result": "Completed the research. List attached in Basecamp.",
"completedAt": "2026-05-29T09:15:00.000Z"
}| Status | Meaning |
|---|---|
pending | Filed, not on the board |
in_progress | On the board. awaitingCustomerConfirm means the delivery is done and you have not closed it yet. |
completed | You closed it. Only client_complete. |
failed | The existing failed status only. A missing board card is a hold, not a failure. |
cancelled | You cancelled it. Stop polling. Not a failure, and not a hold. No completion webhook. |
awaitingCustomerConfirm is present only when the delivery is marked done and you have not closed it. It can appear on pending (no board card) or in_progress. amNote is the AM or VA note. deliverableLinks lists https links from that note and the board card. autoConfirmNote is a close note stored on the job when one was recorded.
{
"taskId": "atask_a3f9c2d1e4b5a6f7",
"status": "in_progress",
"awaitingCustomerConfirm": true,
"amNote": "Submitted to the five directories. https://example.com/proof",
"deliverableLinks": ["https://example.com/proof"],
"vaCompletedAt": "2026-10-16T18:00:00.000Z"
}/api/v1/tasks/:ref/confirm
Accept the delivery. This settles tracked hours, releases the board and ledger hold, and marks the task completed. Send header Idempotency-Key. A repeat of the same key returns the stored response. A different action or body with that key is 409 idempotency_conflict.
The job stays open until the customer accepts it here or says never mind with cancel. A cron does not complete it.
curl -X POST https://taskbullet.com/api/v1/tasks/atask_a3f9c2d1e4b5a6f7/confirm \
-H "x-api-key: tb_live_YOUR_KEY" \
-H "Idempotency-Key: confirm-atask_a3f9c2d1e4b5a6f7"/api/v1/tasks/:ref/cancel
Customer never mind. Releases the hold and bills only tracked time. Does not send task.completed. Optional JSON reason. Header Idempotency-Key is required.
curl -X POST https://taskbullet.com/api/v1/tasks/atask_a3f9c2d1e4b5a6f7/cancel \
-H "x-api-key: tb_live_YOUR_KEY" \
-H "Idempotency-Key: cancel-atask_a3f9c2d1e4b5a6f7" \
-H "Content-Type: application/json" \
-d '{ "reason": "No longer needed" }'/api/v1/tasks/:ref/reopen
Send the delivery back to the account manager. reason is required. This does not settle hours. Header Idempotency-Key is required.
curl -X POST https://taskbullet.com/api/v1/tasks/atask_a3f9c2d1e4b5a6f7/reopen \
-H "x-api-key: tb_live_YOUR_KEY" \
-H "Idempotency-Key: reopen-atask_a3f9c2d1e4b5a6f7" \
-H "Content-Type: application/json" \
-d '{ "reason": "The directory list is missing two names" }'Calls
A human phone call uses place_call, not delegate_task. place_call dials. delegate_task files a brief and waits for the customer to click Start. It does not dial. Calls are pay as you go from the calls wallet. Only the US and Canada can be placed today. A priced country that is not live returns 422 country_not_enabled with country and tier. Calls routes share the 60 requests per minute per key limit on this instance. A 429 response includes the Retry-After header in seconds.
Required fields
phone, purpose (info, scheduling, account_servicing, vendor_ops), goal (at most 4000 characters), questions, deadline (ISO-8601), and consent_attestation true. Signup stores business_name and on_behalf_of on the account. A call that omits either field uses the stored value. A call that sends a value uses that value. If the account has no stored value and the call omits the field, the call is needs_info.
A question is { id, ask, type? }. ask is at least 8 characters and at most 1000. A shorter ask is needs_info too_short on questions[n].ask, with limit 8 and a message that names that field. At most 20 questions. An over-long goal or question is 422 field_too_long with field and limit. It is not truncated.
Optional fields
language (default en), record_call false or omitted, script_mode, script, instructions, extension (digits, passed into the VA instructions, not billed), time_window as 24-hour HH:MM-HH:MM with the end after the start, recipient_timezone, allow_outside_business_hours, max_attempts (default 3, max 5), max_minutes (default 5, max 60), and callback_url (https). Call webhooks are delivered to callback_url.
US and Canada numbers such as 801-414-4664, (801) 414-4664, and +1 801 414 4664 are stored as E.164. If recipient_timezone is omitted, TaskBullet infers it from the area code and says so with recipient_timezone_inferred. An uninferable number stays needs_info. There is no UTC fill-in.
Endpoints
- POST /api/v1/calls/validate — no call and no hold. Returns hold_cents and whether the time zone was inferred. valid stays true when spendable balance is below the hold, and warnings includes insufficient_balance with needed_cents and addFundsUrl.
- POST /api/v1/calls — Idempotency-Key required. The first queued create is 201. Replaying the same key and body returns 200 and the current call.
- GET /api/v1/calls/{id} — a queued call includes billing.hold_cents, billing.charged false, and attempts [].
- PATCH /api/v1/calls/{id} — needs_info only.
- POST /api/v1/calls/{id}/cancel — releases the hold before the first dial.
- GET /api/v1/calls
- GET /api/v1/calls/{id}/transcript.txt — the VA wrap-up summary, not a recording. Once the call is wrap_up or completed, the body is that summary and any question answers, with source wrap_up_summary and recorded false. Before that summary exists, the response is transcript_not_ready. A call that ended without a dial is "no transcript: call not placed". A cancel after the dial started is call_cancelled. A recorded transcript may be a separate paid feature later and is not returned.
- GET /api/v1/balance — balance_cents, wallet_cents, holds_cents, addFundsUrl.
- POST /api/v1/billing/add-funds — body topup_cents. Optional return_url is where the browser goes after Checkout. Same-origin returns automatically. An https URL on another host is a continue link on the return page. GET does not create a Stripe session.
- POST /api/v1/webhooks/test — body { url } (https). Sends one sample call.completed, the same shape as a live completion, with test: true. Signed with the account webhook secret. No call, no hold, no ops. Safe to send again. 10 per minute per key. 200 returns status_code, latency_ms, event_id, and headers.
- POST /api/v1/webhooks/receiver/{token} — first-party inbox. Stores headers, signature, timestamp, raw body, and signature_valid. Returns 200. GET /api/v1/webhooks/receipts issues receiver_url when missing and lists receipts for the account that owns the key.
- POST /api/v1/accounts — name, email, company, business_name, and on_behalf_of. Optional return_url. The same form is /hub/get-api-key. 201 returns api_key and webhook_secret once.
- PATCH /api/v1/accounts/me — settings, including business_name and on_behalf_of. The webhook secret is shown once, and again only on rotate.
Statuses, outcomes, and webhooks
Statuses: needs_info, queued, assigned, in_progress, completed, failed, blocked, cancelled. Finished outcomes include success, partial, no_answer, voicemail, voicemail_left, busy, wrong_number, refused, ended_insufficient_funds, and expired. A call that is still in progress does not report no_answer.
Webhook events: call.needs_info, call.queued, call.assigned, call.in_progress, call.attempted, call.redial, call.completed, call.failed, call.blocked, call.cancelled, call.auto_topup_failed. call.redial includes next_attempt_at and attempt. The signature is x-taskbullet-timestamp (unix seconds) and x-taskbullet-signature, hex HMAC-SHA256 of {unixSeconds}.{rawBody}, with no sha256= prefix.
Hold, connected charge, and tiers
The hold is the full call: the tier minimum for the first minute, then the per-minute rate for each additional minute. Default max_minutes is 5. Tier 1 is $2.50 for the first minute and $1.00 for each additional minute, so the default hold is $6.50 (650 cents). Raise max_minutes on that call to hold more. A call that never connected is not charged. Refused, never rang, no answer, busy, disconnected, and no connection release the hold, including the tier minimum. A connected call is billed. Voicemail and a wrong number count as connected. Unused hold is released. Settlement uses the quote that hold paid for, and never charges more than that hold. A call placed at the older price stays on that price. A charge that already settled is left as it is. Redials after no-answer, voicemail, or busy are free. Ring time is not billed. A dial is a real attempt: an attempt row, a dial outcome such as no answer, busy, voicemail, a wrong or disconnected number, or bad information the VA worked, or a submitted wrap-up. If that attempt happened and the completion never arrived, the stall backstop completes the call. A connected attempt is charged the quote that hold paid for. An unconnected attempt releases the hold. A refund with no completion happens only when no dial was made. That covers a failed handoff, a call that was never claimed or dialed, an ops or desk error, and a call that expired with zero attempts. The sweep claims settlement once.
Use cases: vendor quotes, appointment confirmations, availability and price checks, lead follow-up.
| Tier | First minute | Each additional minute | Countries |
|---|---|---|---|
| 1 | $2.50 | $1.00 | US, Canada, Puerto Rico |
| 2 | $3.00 | $1.25 | US Virgin Islands, Singapore, Bermuda, Guam, India, Australia, New Zealand |
| 3 | $3.50 | $1.50 | Northern Mariana Islands, Ireland, Nigeria, Philippines, Trinidad and Tobago, Gibraltar, Kenya |
| 4 | $4.50 | $2.00 | Cayman Islands, American Samoa, Bahamas, Jamaica, British Virgin Islands, South Africa, United Kingdom, Belize, Ghana, Barbados, Malta, Turks and Caicos |
Only US and Canada are live. Premium and toll-free numbers return number_blocked.
Calls errors
413 payload_too_large names the 32 KiB limit. 422 country_not_enabled, country_quote_only, number_blocked, field_too_long, and do_not_call are top-level codes. too_short names the question field when ask is under 8 characters. needs_lead_time includes a message when the deadline is under 60 minutes of clock time. needs_business_hour means the deadline leaves 60 minutes of clock time but not 60 weekday 09:00–17:00 minutes in the recipient time zone; set allow_outside_business_hours after the customer confirms, or move the deadline. 429 includes Retry-After. A huge body is 413, not a generic failure.
Email actions
request_action asks a human to send one email. It holds a flat fee on the prepaid wallet (reason ACTION, default 500 cents, ACTION_EMAIL_FEE_CENTS). It does not use VA hours and it is not place_call.
The caller passes provider google, a one-time delegated_credential, credential_scope (recipients, subject, and message), and credential_expires_at. Gmail cannot bind a token to one message. TaskBullet refuses the request when the scope does not match, and it revokes the token after send, failure, cancel, or expiry. A missing, expired, or mismatched credential is needs_info and is not charged. The token is never returned and is never sent to the VA.
The client approves the final text with approve_action before anything is sent. A VA question comes back as needs_info; answer it with update_action and follow_up_answer. Contract: docs/actions-contract.md.
- POST /api/v1/actions — Idempotency-Key required.
- POST /api/v1/actions/validate — no row and no hold.
- GET /api/v1/actions and GET /api/v1/actions/{id}.
- PATCH /api/v1/actions/{id} — fill needs_info or answer a follow-up.
- POST /api/v1/actions/{id}/approve — consent_attestation true.
- POST /api/v1/actions/{id}/cancel — releases the hold when the email has not been sent.
Webhooks
If you pass webhookUrl when delegating, TaskBullet will POST task.awaiting_confirmation when an agent-created delivery is marked done, and task.completed when you close the job (client_complete). A customer cancel does not send task.completed. Verify the signature before processing.
Payload
POST https://your-app.com/hooks/taskbullet
Content-Type: application/json
x-taskbullet-signature: <hex>
x-taskbullet-timestamp: <unix seconds>
{
"event": "task.completed",
"taskId": "atask_a3f9c2d1e4b5a6f7",
"taskTitle": "Book a flight to Austin",
"status": "completed",
"result": "Booked on Delta flight 1423, confirmation #XYZ.",
"timestamp": "2026-05-29T09:15:00.000Z"
}{
"event": "task.awaiting_confirmation",
"taskId": "atask_a3f9c2d1e4b5a6f7",
"taskTitle": "Submit the public MCP to 5 directories",
"status": "in_progress",
"result": "Submitted. Proof is in the note.",
"awaitingCustomerConfirm": true,
"amNote": "Submitted. Proof is in the note.",
"deliverableLinks": ["https://example.com/proof"],
"timestamp": "2026-10-16T18:00:00.000Z"
}Signature verification (Node.js)
import crypto from 'crypto';
function verifyWebhook(req, webhookSecret) {
const sig = req.headers['x-taskbullet-signature'];
const ts = req.headers['x-taskbullet-timestamp']; // Unix seconds
const body = req.rawBody; // RAW request body string — NOT JSON.stringify(parsed)
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(`${ts}.${body}`)
.digest('hex');
// Use timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expected, 'hex'),
);
}Important: x-taskbullet-timestamp is Unix seconds (not milliseconds), and rawBody must be the exact bytes received from the request — not a re-serialized JSON string. Most frameworks expose this as req.rawBody or via a buffer-capture middleware. Re-parsing and re-stringifying will produce a different byte sequence and the signature will not match.
The webhookSecret is the value shown at API key creation time — separate from the API key itself. Retries are attempted up to 5 times (every ~15 min) before giving up. Call events are delivered to the callback_url on the call. POST /api/v1/webhooks/test with { "url": "https://..." } sends one sample call.completed marked test: true, signed the same way. It creates no call and places no hold. POST /api/v1/webhooks/receiver/{token} is a first-party inbox for an unguessable per-account token. It stores the headers, signature, timestamp, raw body, and whether the signature matches the webhook secret for that account, then returns 200. GET /api/v1/webhooks/receipts with the API key issues receiver_url when the account does not have one, then lists recent receipts for that account. The body cap is 32 KiB.
Errors
| Status | Cause |
|---|---|
400 | Missing or invalid body, oversize fields, a missing Idempotency-Key, or secrets in title/description |
409 | not_awaiting_confirm, task_closed, idempotency_conflict, or idempotency_in_progress |
401 | Missing, invalid, or revoked API key |
402 | Live Ops hours are not above zero — addHoursUrl is /packages-pricing (not trial) |
422 | Requested cap does not fit open caps. Hours remain. Lower maxHours to capRoom or wait. No addHoursUrl. |
413 | Request body larger than 32 KiB |
429 | Rate limit exceeded (60 req / 60s on this instance; it does not hold across instances) — respect Retry-After |
500 | Internal server error |
Error responses include a JSON body with error. 402 also includes hoursRemaining and addHoursUrl. 422 also includes hoursRemaining and capRoom.
Ready to integrate?
Issue your API key from the dashboard settings, then connect your AI agent or automation workflow.
Get your API key