# Lovelio API reference

Recruitment automation API. Create jobs, manage candidates, run ad-hoc actions, integrate with your tools.

Generated from the OpenAPI 3.1 document at https://lovelio.ai/api/v1/openapi.json.
API version 1.0.0. 161 paths, 239 operations.

This is the plain-markdown copy of the reference rendered at https://lovelio.ai/docs/api.

## Fetch less than all of this

| URL | What you get |
| --- | --- |
| `https://lovelio.ai/docs/api.md` | Everything: this header plus every operation in full. |
| `https://lovelio.ai/docs/api.md?index=1` | This header plus the endpoint index. No per-operation detail. |
| `https://lovelio.ai/docs/api.md?tag=Jobs` | This header plus one section in full. Comma-separate for several: `?tag=Jobs,Applications`. |
| `https://lovelio.ai/api/v1/openapi.json` | The machine-readable source this file is generated from. |

Tags: System, Accounts, Jobs, Job Ads, Candidates, Candidate Imports, Applications, Interviews, Submissions, Placements, Quotas, Marketplace, Webhooks, Batch, Activities, Documents, Tasks, Outreach, Talent Pools, Review Queue, Forms, Referees, Integrations, Chat Integrations, Analytics, Calendar, Scheduled Emails, Workflow Rules, Email Templates, Stages, Distribution, Clients, Specs, Search, Business Development.

## Base URL

Docs live on lovelio.ai. The API does not. Every workspace belongs to a region and an API key only works on its own region's host. Calling `lovelio.ai/api/v1/...` returns a JSON error naming all three hosts rather than data.

| Region | Base URL |
| --- | --- |
| Production (US (N. California)) | `https://us.lovelio.ai/api/v1` |
| Production (EU (London)) | `https://eu.lovelio.ai/api/v1` |
| Production (ANZ (Sydney)) | `https://anz.lovelio.ai/api/v1` |

A workspace's exact host is shown in Settings > API keys.

## Authentication

```http
Authorization: Bearer sk_live_...
```

API key, sent as Authorization: Bearer <key>. Three key types: sk_live_ (production), sk_test_ (development - same workspace, same data, marked as a test key; use a separate workspace if you need isolated test data), and sk_trial_ (issued at signup, expires 7 days later - swap to a live key from Settings > API keys). Keys are server-generated, shown once at creation, and scoped per permission (or admin for everything). Lovelio Connect apps authenticate with the OAuth access token from the connect flow instead (Authorization: Bearer lc_at_...) - it carries exactly the scopes the agency approved, enforced on every request. Money is gated at FIELD level, not route level: the three scopes placements:financials:read, clients:financials:read and marketplace:financials:read unlock salary, fee, contract rates, expected GP, commission percents, the client fee schedule and split-fee deal amounts. Without them those fields return null and everything else on the record still comes through - a read never 403s for want of a money scope. admin satisfies all three. Requesting any of them puts a Connect app in the elevated review tier. See /docs/agents/build-an-integration.

## Pagination and shared parameters

Paginated list endpoints declare the parameters they support below. For cursor-based lists, read `meta.next_cursor` from a response and pass it back as `after`. Bounded catalogue and configuration lists return their full result and do not take a cursor.

| Name | In | Type | Description |
| --- | --- | --- | --- |
| `limit` | query | integer, 1-100, default 25 | Rows per page. Clamped to the 1-100 range rather than rejected, so asking for 500 returns 100 - read meta.has_more and page with the cursor. Unparseable values fall back to 25. |
| `after` | query | string | Cursor for forward pagination |
| `created_after` | query | string (date-time) | Only rows created at or after this ISO 8601 date or datetime (inclusive). Malformed values are a 422, never silently ignored. |
| `created_before` | query | string (date-time) | Only rows created at or before this ISO 8601 date or datetime (inclusive). Malformed values are a 422, never silently ignored. |
| `Idempotency-Key` | header | string | Unique key for this mutation. Repeating the same request with the same key within 24 hours returns the saved response without running it again. |

## Rate limit headers

| Header | Type | Description |
| --- | --- | --- |
| `X-RateLimit-Limit` | integer | Requests allowed per minute |
| `X-RateLimit-Remaining` | integer | Requests remaining in window |
| `X-RateLimit-Reset` | integer | Unix timestamp when window resets |

## Guides

- https://lovelio.ai/docs/agents - what an agent can drive end to end
- https://lovelio.ai/docs/agents/top-10-tasks - runnable examples for the ten most common calls
- https://lovelio.ai/docs/agents/build-an-integration - sandbox agency, test API key, live webhook testing
- https://lovelio.ai/docs/agents/error-handling - error envelope, request IDs, retries, idempotency keys
- https://lovelio.ai/docs/agents/webhooks - subscribing and verifying HMAC signatures
- https://lovelio.ai/docs/agents/sdks - single-file TypeScript and Python clients
- https://lovelio.ai/docs/api/changelog - public API release history
- https://lovelio.ai/llms-full.txt - all of the above inlined in one fetch

## Endpoint index

Filtered to: Batch.

### Batch

Execute multiple canonical actions in a single request with per-op idempotency

- `POST /batch` - Execute multiple actions in one request

---

## Batch

Execute multiple canonical actions in a single request with per-op idempotency

### POST /batch

**Execute multiple actions in one request**

Dispatch up to 100 canonical actions in a single round-trip. Each operation runs independently: one failing does not prevent later operations. Each has its own idempotency key so an agent can safely retry a single failed op without re-executing the ones that succeeded. The whole batch counts as one call against the per-minute rate limit. Permission for each operation is checked per-op against the API key scopes.

The callable action catalogue is generated from the same action registry the product dispatches and the same scope map this endpoint enforces.

| Action | Required scope | Payload | What it does |
| --- | --- | --- | --- |
| `add_client_contact` | `clients:write` | `{ client_id? \| client_name?, name, email?, phone?, title?, reports_to?, is_primary?, source? }` | Add a contact (hiring manager, HR, finance) to a client. Pass client_id, or client_name when you only know the company - a client with that name is matched, and created if the agency does not have one yet. A contact with the same email on the same client is handed back rather than duplicated. |
| `add_client_dna_note` | `clients:write` | `{ client_id, text, source? }` | Add a note to a client's DNA - anything learned from a meeting, call, or feedback. The DNA re-derives automatically with the note applied. source is text (default), voice, or meeting. |
| `add_client_recruiter` | `clients:write` | `{ client_id, member_id }` | Add a teammate as a recruiter on a client. Anyone who works a job at the client is added automatically - this is the manual way to add anyone else. |
| `add_note` | `activities:write` | `{ record_type, record_id, note_text }` | Add a note to a candidate / job / application / interview / client record. |
| `add_quota_adjustment` | `accounts:write` | `{ amount, reason, user_id?\|user_email?, currency?, period_start? }` | Credit or debit a consultant's quota figure (or the agency's, with no consultant named) inside a period, with a reason - a deal counted in a different period, a split correction. Amount is signed: negative debits. Defaults to the current period; locked periods refuse. |
| `add_referee` | `candidates:write` | `{ application_id, name, email, relationship?, phone? }` | Record a referee the candidate gave directly (name + email, optional relationship and phone) against their application. For asking the candidate to submit their own referees, use request_referee_details instead. |
| `add_to_talent_pool` | `candidates:write` | `{ candidate_id, pool_id? \| pool_name?, reason? }` | Add a candidate to a talent pool. Pass pool_id, or pool_name when the user names the pool (e.g. "add Jane to the engineering pool"). |
| `answer_spec_question` | `submissions:write` | `{ spec_id, body }` | Answer a question a client asked about a spec (float). The reply is stored on the spec's Q&A thread and the client contact is emailed the answer. |
| `answer_submission_question` | `submissions:write` | `{ item_id, body, resend_link? }` | Answer a question a client asked about a submitted candidate, or send them any message on that submission. The client contact is emailed it. Pass resend_link: true when the message is sending the client link again - the link is renewed first (another 7 days, and an expired one comes back to life), so the client never gets a dead link. A revoked submission is never resent. |
| `approve_join_request` | `talent_pools:write` | `{ request_id }` | Approve a pending talent pool join request. Creates or matches the candidate record and adds them to the pool. |
| `ask_analytics` | `analytics:read` | `{ question }` | Ask any data question over the agency's records - billings, placements, submissions, ratios, fill rate, rankings by client or consultant, trends, period comparisons. A deterministic engine computes every number and the answer states exactly what was computed. |
| `ask_bd_target` | `clients:read` | `{ target_id \| target_name, question }` | Ask any question about an employer on the BD territory map - what they are hiring, advertised salaries, why they score, what has changed. Answered only from the map's own evidence, and it never recommends outreach. |
| `ask_candidate` | `candidates:read` | `{ candidate_id \| candidate_name, question }` | Ask any question about a candidate in natural language - history with the agency, live applications, client responses, when you last spoke. Answered only from the agency's own records for that candidate, never from outside data. |
| `ask_client` | `clients:read` | `{ client_id \| client_name, question }` | Ask any question about a client in natural language - reply speed, placement history, who to call, whether they are a good client, and grounded public BD signals. Public facts always keep their verified source. |
| `ask_job` | `jobs:read` | `{ job_id \| job_title, question }` | Ask any question about a job in natural language - how the pipeline is tracking, who is stalled, what is waiting on the client. Answered only from the agency's own records for that job, never from outside data. |
| `bulk_add_to_talent_pool` | `candidates:write` | `{ candidate_ids: [...], pool_id? \| pool_name? }` | Add multiple candidates to a talent pool at once. |
| `bulk_reject` | `applications:write` | `{ application_ids: [...] }` | Reject multiple applications at once. |
| `bulk_send_email` | `emails:write` | `{ candidate_ids: [...], subject, body }` | Send the same email to multiple candidates. |
| `cancel_interview` | `interviews:write` | `{ interview_id, reason? }` | Cancel a scheduled interview. Sends a cancellation notice. |
| `cancel_meeting` | `interviews:write` | `{ event_hint, reason? }` | Cancel a previously booked meeting. Pass the user's phrasing verbatim as event_hint. Soft-deletes the row (cancelled_at) and removes the Google event. |
| `cancel_scheduled_email` | `emails:write` | `{ scheduled_email_id }` | Cancel an email that has been scheduled but not yet sent. |
| `chase_spec` | `submissions:write` | `{ spec_id, email_subject, email_body }` | Nudge the client contact about a spec they have not responded to. The email body is written by the consultant (or pre-drafted for them) - it is never sent without a person approving it. Stamps the reminder and clears the Needs You prompt. |
| `chase_submission` | `submissions:write` | `{ submission_id, email_subject, email_body }` | Nudge the client contact about a submission they have not responded to. The email body is written by the consultant (or pre-drafted for them) - it is never sent without a person approving it. Stamps the reminder and clears the Needs You prompt. |
| `close_job` | `jobs:write` | `{ job_id, reason? }` | Close a job (sets status=Filled). Existing applications are preserved. |
| `complete_assessment` | `applications:write` | `{ candidate_id, job_id }` | Fill in the assessment stages the arrival cascade skipped (gated Client Fit, deferred summary) for a candidate on a job. No-op when the assessment is already complete; never re-scores what was scored. |
| `confirm_candidate_draft` | `candidates:write` | `{ review_token, name?, email?, phone?, linkedin_url?, location?, current_title?, current_company? }` | Apply any final field corrections, then create the real candidate record. Refuses with draft_has_gaps if name or email is empty. Dispatches to the existing create_candidate handler. |
| `confirm_email_draft` | `emails:write` | `{ review_token }` | Send the staged email now (or schedule it if schedule_at is set). Refuses with draft_has_gaps if subject or body is empty. Dispatches to the existing send_email / schedule_email handlers. |
| `confirm_interview_action_draft` | `interviews:write` | `{ review_token, new_scheduled_at?, ignore_conflicts? }` | Reschedule or cancel the interview now. Refuses with draft_has_gaps if new_scheduled_at (reschedule) or reason (cancel) is empty. Dispatches to the existing reschedule_interview / cancel_interview handler. A reschedule clash refuses with code "conflicts" naming who is busy; pass new_scheduled_at to re-confirm at a suggested time, or ignore_conflicts: true only after the user explicitly says to move it anyway. |
| `confirm_interview_draft` | `interviews:write` | `{ review_token, scheduled_at?, ignore_conflicts? }` | Schedule the interview now. Refuses with draft_has_gaps if scheduled_at, format, or interviewer_id is empty (or if format is in_person without a location). Dispatches to the existing schedule_interview handler. A free/busy clash refuses with code "conflicts" naming who is busy; pass scheduled_at to re-confirm at a suggested time, or ignore_conflicts: true only after the user explicitly says to book anyway. |
| `confirm_interview_form_draft` | `interviews:write` | `{ instance_id }` | Submit the interview form. Refuses with draft_has_gaps if overall recommendation is missing or no section has been scored. Dispatches to the existing update_interview_outcome handler. |
| `confirm_job_draft` | `jobs:write` | `{ review_token, mode: "active"\|"draft", client_id?, team_id?, hiring_manager_id? }` | Create the real job and fire the full create_job pipeline (criteria, interview questions, ad, social posts). Refuses if any mandatory gap is unfilled. |
| `confirm_phone_screen_draft` | `applications:write` | `{ review_token }` | Atomically log a timeline note and move the application stage. advance -> shortlisted (via move_stage); reject -> rejected (via reject_candidate); hold / no_show -> stage unchanged. Refuses with draft_has_gaps if outcome or notes is empty. |
| `confirm_scorecard_draft` | `interviews:write` | `{ review_token }` | Submit the staged scorecard. Refuses with draft_has_gaps if overall_recommendation is missing or every score is null. Dispatches to the existing submit_scorecard + update_interview_outcome handlers. |
| `convert_bd_lead` | `clients:write` | `{ lead_id, website? }` | Convert a business development lead into a real client record. Enrichment and Client DNA kick off automatically (website inferred from the ad contact when not given). No job is created - the consultant briefs the role through the normal flow. |
| `convert_bd_target` | `clients:write` | `{ target_id, website? }` | Convert a territory-map target into a real client record. Enrichment and Client DNA kick off automatically (website inferred from ad contact evidence when not given). No job is created - the consultant briefs the role through the normal flow. |
| `convert_spec` | `jobs:write` | `{ spec_id, title?, location?, employment_type?: full_time\|part_time\|contract, salary_min?, salary_max?, currency? }` | The client is interested in a specced candidate: create the job at that client and add the candidate at the submitted stage in one step. Uses the real job + application creation paths; the spec keeps the introduction record. |
| `create_application` | `applications:write` | `{ candidate_id \| candidate_ids: [...], job_id, stage?, source_category?, source_detail?, talent_pool_id?, search_query?, sourcing_run_id?, notes? }` | Attach an existing candidate (or many) to an existing job. Pass candidate_id for a single attach or candidate_ids: [...] for bulk. The "new" stage is the default. Credit the source: pass talent_pool_id when attaching from a talent pool, search_query when attaching a database result, or sourcing_run_id for an explicit LinkedIn sourcing run. |
| `create_candidate` | `candidates:write` | `{ name, email?, phone?, location?, skills? }` | Create a new candidate profile. |
| `create_client` | `clients:write` | `{ name?, website?, phone?, industry?, description?, locations?, address?, standard_fee_percent?, payment_terms_days?, guarantee_days?, notes? }` | Create a client (a company the agency recruits for). The company phone is separate from every contact phone. Pass a website and Lovelio fills in missing details automatically. Initial agreed fee terms (e.g. importing your book) may be set here; later changes go through set_client_fee_terms. |
| `create_distribution_rule` | `jobs:write` | `{ name, target_board_slugs: string[], filter?: { country?, category?, job_type?, team_id? }, active? }` | Create a job-board distribution rule: matching jobs auto-publish to the target boards. Filter by country, category, job_type, or team. |
| `create_job` | `jobs:write` | `{ title, client_id \| client_name, client_contact_id?, location?, type?, salary_min?, salary_max?, currency?, default_interview_format?, is_evergreen? }` | Create a new job requisition. Every job belongs to a client: pass client_id (or client_name - one unambiguous match). For internal hiring use the agency self-client. |
| `create_job_ad` | `jobs:write` | `{ job_id, title?, description? }` | Create a new public job ad for a role. |
| `create_placement` | `placements:write` | `{ application_id? \| (candidate_id + client_id), job_id?, salary?, currency?, fee_type?, fee_percent?, fee_amount?, start_date?, guarantee_days?, owner_id?, notes?, placement_type?, offer_date?, contract_end_date?, charge_rate?, pay_rate?, rate_period?, expected_gp?, credits? }` | Record a placement: a candidate placed with a client for a fee. Pass application_id to log it against a live application (moves it to Placed), or candidate_id + client_id - the candidate must already be live on one of the client's OPEN jobs (Active or On Hold), and the placement links to that application. A closed or filled job is refused: reopen it first. Fee percent and guarantee default off the client's standard terms. |
| `create_reminder` | `interviews:write` | `{ title, starts_at, notes?, candidate_id?, job_id?, application_id? }` | Create a reminder for the user at a specific time. |
| `create_submission` | `submissions:write` | `{ job_id, application_ids: [...] \| items: [{ application_id, summary? }], client_contact_id?, email_subject?, greeting?, headline?, intro_body?, message?, title?, identity_visibility? }` | Send a submission of candidates on a job to the job's client contact for review. The client gets a no-login link to request interviews, pass, or ask questions. Candidates still in triage move to the submitted stage automatically. identity_visibility decides how much of each candidate the client sees: "full" (default), "first_name" (first name only, no surname or LinkedIn), or "anonymous" (no name, and employers and schools withheld too). email_subject is the subject line the client sees - pass one when the consultant has written it, otherwise it defaults to "Candidates for the role of {job}" (singular "Candidate" for a submission of one). headline is the big line at the top of the client review page - pass one to replace the default ("Hi {first name}, N candidates for your review."). intro_body is everything said above the candidates in the consultant's own words - pass one to replace the default paragraph ("I am delighted to submit {first names} for your role of {job title}. Please see specific details below..." signed off by the consultant). |
| `create_talent_pool` | `talent_pools:write` | `{ name, eligibility?, description?, cover_color? }` | Create a new talent pool. Name is required; eligibility is the one-line "who belongs here" description. |
| `create_webhook` | `webhooks:write` | `{ url, events?: string[] }` | Create a webhook subscription. Generates a signing secret returned once. |
| `decline_join_request` | `talent_pools:write` | `{ request_id, note? }` | Decline a pending talent pool join request. Silent - the applicant is not emailed. |
| `delete_candidate` | `candidates:write` | `{ candidate_id, reason? }` | Soft-delete a candidate (not GDPR hard delete). |
| `delete_client` | `clients:write` | `{ client_id, reason? }` | Delete a client record. |
| `delete_client_contact` | `clients:write` | `{ contact_id }` | Remove a contact from a client. |
| `delete_document` | `documents:write` | `{ document_id }` | Delete one document. Removes the file itself, not just the listing - there is no undo. |
| `delete_job` | `jobs:write` | `{ job_id, reason? }` | Soft-delete a job. Applications are preserved but the job is hidden. |
| `delete_talent_pool` | `talent_pools:write` | `{ pool_id? \| pool_name? }` | Permanently delete a talent pool. The pool and its membership list go; every candidate stays in your bank. Pass pool_id, or pool_name when the user names the pool. |
| `delete_webhook` | `webhooks:write` | `{ webhook_id }` | Delete a webhook subscription. Past deliveries are preserved. |
| `describe_a_job` | `jobs:write` | `{ description, client_id? \| client_name?, autopublish?, source? }` | Turn a free-text role description into a full job draft (streaming in the background). Returns a review_token that every follow-up action uses. |
| `dismiss_bd_lead` | `clients:write` | `{ lead_id }` | Dismiss a business development lead. Forever: a dismissed employer is never suggested again. |
| `dismiss_bd_target` | `clients:write` | `{ target_id }` | Dismiss a territory-map target. Forever: it leaves the map and the employer is never suggested as a lead again. |
| `email_client_contact` | `emails:write` | `{ client_contact_id, subject, body }` | Send an email to a contact at a client now. Tracked on the client timeline; replies thread back into Lovelio. |
| `extract_candidate_preferences` | `candidates:write` | `{ candidate_id } then { candidate_id, apply: true, patch }` | Read the candidate's recent call notes and timeline notes and propose a preferences patch (preview first; { apply: true, patch } writes the confirmed proposal). |
| `get_candidate_draft` | `candidates:read` | `{ review_token }` | Read the current state of a staged candidate draft plus remaining mandatory and warn-level gaps. |
| `get_email_draft` | `emails:read` | `{ review_token }` | Read the current state of a staged email draft plus remaining gaps and the draft-generation task status. |
| `get_interview_action_draft` | `interviews:read` | `{ review_token }` | Read the current state of a staged reschedule / cancel draft plus remaining gaps. |
| `get_interview_draft` | `interviews:read` | `{ review_token }` | Read the current state of a staged interview draft plus remaining gaps. |
| `get_interview_form_draft` | `interviews:read` | `{ instance_id }` | Read the current state of an interview form: instance, sections, scores, recommendation, gaps. |
| `get_job_creation_status` | `jobs:read` | `{ task_id }` | Poll a create_job task runner for per-step progress (description, criteria, questions, ad, social). |
| `get_job_draft` | `jobs:read` | `{ review_token }` | Read the current state of a streaming draft plus remaining gaps and task status. |
| `get_phone_screen_draft` | `applications:read` | `{ review_token }` | Read the current state of a staged phone-screen draft plus remaining gaps. |
| `get_scorecard_draft` | `interviews:read` | `{ review_token }` | Read the current state of a staged scorecard draft plus remaining mandatory and warn-level gaps. |
| `invite_team_member` | `accounts:write` | `{ email, role ('admin'\|'team_leader'\|'recruiter'), name?, team_id? \| team_name? }` | Invite a new team member by email with a role. Sends a magic-link invitation email. |
| `list_documents` | `documents:read` | `{ record_type, record_id }` | List the documents filed against any record - a candidate, client, contact, job, placement, talent pool or BD target. |
| `log_bd_target_touch` | `clients:write` | `{ target_id, note? }` | Record that you reached out to a territory-map target (call, coffee, email you sent yourself). Resets the cadence clock. Lovelio never drafts or sends the outreach. |
| `log_call` | `activities:write` | `{ candidate_id? \| client_contact_id? (+ client_id?), outcome? (connected\|voicemail\|no_answer), notes?, called_at?, spec_id?, submission_id? }` | Record a phone call with a person: a candidate, or a contact at a client (never a bare company - pass client_contact_id and client_id resolves from it). Feeds the timeline and the "never called" search filters. On a client call, pass spec_id or submission_id to make the call count as the chase: the reminder stamps and the follow-up card resolves, same as the emailed nudge. |
| `log_offer` | `applications:write` | `{ application_id, offered_salary?, offered_currency?, offer_date? }` | Record that the client has made an offer to the candidate and move the application to the Offer stage. Stores the offered salary and offer date on the application. In agency perm the client owns the offer document - this is a stage marker with the offer's details, not an offer letter. |
| `mark_placement_status` | `placements:write` | `{ placement_id, status, reason? }` | Mark a placement as pending start, started, fell off (during guarantee), or completed. |
| `match_candidates_to_job` | `candidates:read` | `{ job_id, limit? }` | Search the whole candidate database for the best matches to a job, ranked with a short why-them line per candidate. Read-only: attach a match with create_application (pass search_query so the source is credited as Database Search). |
| `merge_candidates` | `candidates:write` | `{ primary_id, duplicate_id }` | Merge a duplicate candidate into a primary candidate. |
| `move_stage` | `applications:write` | `{ application_id, new_stage, reason? }` | Move an application to a different pipeline stage. |
| `plan_linkedin_sourcing` | `jobs:read` | `{ job_id, force? }` | Build four LinkedIn sourcing searches from a job's full structured requirements, prior placement signals and explicit search feedback. Returns direct, adjacent, feeder-company and hidden-gem lanes. It never runs or automates LinkedIn. |
| `prepare_cancel_interview` | `interviews:write` | `{ interview_id, reason?, notify_candidate? }` | Stage a cancel draft for an existing interview. Reason is mandatory before confirm. Returns a review_token every follow-up action uses. |
| `prepare_candidate_from_cv` | `candidates:write` | `{ file_base64, mime_type?, file_name?, initial?: { name?, email?, phone?, linkedin_url?, location? }, slack_file_id?, source? }` | Parse an uploaded CV and stage a candidate draft with pre-filled details. Typed details in initial take precedence over extracted identity fields, and conflicts are returned for review. Returns a review_token every follow-up action uses. |
| `prepare_compose_email` | `emails:write` | `{ candidate_id, application_id?, intent? (advance\|reject\|schedule\|offer\|freeform), instructions?, schedule_at? }` | Stage an email draft for a candidate. Kicks off a background Haiku draft in the company brand voice. Returns a review_token every follow-up action uses. |
| `prepare_interview_form` | `interviews:write` | `{ interview_id, regenerate? }` | Generate (or return the existing) AI interview form for an interview. One form per interview; idempotent unless regenerate is true. Refuses regenerate after any answer has been saved. |
| `prepare_phone_screen` | `applications:write` | `{ application_id, initial_outcome?, initial_notes?, call_at?, duration_min?, source? }` | Stage a phone-screen log for an application. Outcome is one of advance, reject, hold, no_show; notes are required. Returns a review_token every follow-up action uses. |
| `prepare_reschedule_interview` | `interviews:write` | `{ interview_id, new_scheduled_at?, reason?, notify_candidate? }` | Stage a reschedule draft for an existing interview. Captures the new datetime and optional reason. Returns a review_token every follow-up action uses. |
| `prepare_schedule_interview` | `interviews:write` | `{ application_id, scheduled_at?, format?, interviewer_id?, duration_min?, location?, notes? }` | Stage an interview draft for an application. Predicts format + interviewer from the job defaults so the recruiter has fewer fields to fill. Returns a review_token every follow-up action uses. |
| `prepare_screening_form` | `forms:write` | `{ application_id, mode?: ai \| template, template_form_id?, regenerate?, brief? }` | Load, generate or regenerate the candidate-aware phone screen form for an application. The caller chooses AI or a template for each screen. |
| `prepare_submit_scorecard` | `interviews:write` | `{ interview_id, source? }` | Stage a scorecard draft for an interview. Looks up the job criteria and pre-populates a scores array with one entry per criterion. |
| `promote_client_contact` | `clients:write` | `{ contact_id }` | Turn a "works here" person - someone whose own record says they work at this client - into a contact the agency deals with. They move into the client's main contact list, and approach mail to them is blocked from then on the way it is for every other contact. |
| `propose_interview_slots` | `interviews:read` | `{ interviewer_ids: [user_id, ...], duration_min?, from_date?, to_date?, every_time? }` | Return ~12 candidate day/time options that are free for all of the given interviewers, sourced from the Lovelio calendar (and Google free/busy when connected). Pass every_time: true for the full list of free times inside everyone's preferred hours instead of a spread - that is for a picker UI, never for an answer in chat. Read-only. |
| `pursue_bd_target` | `clients:write` | `{ target_id, stop? }` | Mark a territory-map target as one you are actively pursuing (it pins to the top of the map). Pass stop: true to put it back among the targets. |
| `reassess_application` | `applications:write` | `{ application_id }` | Queue a fresh candidate assessment for an application after its candidate, job requirements or Client DNA changed. |
| `reassign_record` | `accounts:write` | `{ new_owner_id, record_type? + (record_id \| record_ids) \| from_owner_id, keep_team? }` | Move ownership of clients, jobs, candidates, applications, placements, specs or BD targets to another consultant. Records with a team follow the new owner's desk unless keep_team is true. Pass from_owner_id (no ids) to move EVERYTHING a departing consultant owns. |
| `record_client_verdict` | `submissions:write` | `{ item_id, response: "interview_requested" \| "rejected", note?, rejection_reason?, wants_more_candidates? }` | Record what the client said about a submitted candidate when they told you directly instead of using their review link: they want to interview, or they passed (with an optional reason, their words, and whether they want to see more candidates for the role). Same write path as the client portal, so the pipeline, timeline, client DNA and Needs You queue all update identically. |
| `reject_candidate` | `applications:write` | `{ application_id, reason? }` | Reject a candidate (sets stage=rejected; the rejection email only fires for candidates who applied themselves via the careers page or a job board - agency-added candidates are never emailed). |
| `remove_client_recruiter` | `clients:write` | `{ client_id, member_id }` | Remove a teammate from a client's recruiter list. |
| `remove_from_talent_pool` | `talent_pools:write` | `{ candidate_id, pool_id? \| pool_name?, reason? }` | Remove a candidate from a talent pool. Pass pool_id, or pool_name when the user names the pool. |
| `reopen_job` | `jobs:write` | `{ job_id, reason? }` | Reopen a closed or filled job (sets status=Active). |
| `request_interview` | `interviews:write` | `{ application_id, interviewer_ids: [user_id, ...], duration_min, selected_slots: [iso, ...] (1-12, offer-times) \| scheduled_at (iso, set-the-time), client_contact_ids?, email_subject, email_body, format?, location?, video_link?, flavour?, submission_id?, ignore_conflicts? }` | Book an interview, two ways. OFFER TIMES (internal interviews only): a curated set of times goes to the candidate with a "pick a time" button; valid on an application marked Yes, Screen, Submitted, Client interview or References. SET THE TIME (either flavour, whenever the time is already agreed): pass scheduled_at with an exact ISO datetime and the interview books immediately - everyone on it gets the invite and .ics at once. A client set-the-time booking requires at least one client_contact_id (the client hosts it); an internal one requires at least one interviewer (the caller by default) and no client contacts. flavour is internal (default by stage) or client - a client booking moves the application to client_interview. A client interview is the CLIENT meeting the candidate, so it takes no interviewer_ids (nobody from the agency attends), no Video format and no video_link, and it cannot offer times - all four are refused. An internal interview takes no client_contact_ids. location holds an in-person address (the form defaults it to the client address); on an internal Video interview a Lovelio call room is attached unless video_link holds a pasted link. Set-the-time bookings check everyone's free/busy first: a clash refuses with code "conflicts" naming who is busy, plus alternative times. Only re-send with ignore_conflicts: true after the user explicitly says to book anyway. |
| `request_marketplace_candidate` | `marketplace:write` | `{ listing_id, note?, accept_terms? }` | Ask the agency behind a marketplace candidate listing for an intro, with a note on the role you have in mind. If they accept, the candidate's details and both consultants' contacts reveal on the deal and the declared split is locked in. |
| `request_referee_details` | `forms:write` | `{ application_id, min_required (1-5), email_subject?, email_body }` | Email a candidate a secure link to submit their referee contact details (up to 5). The recruiter sets the minimum. Submitted details are saved on the candidate master record and the recruiter is notified when they arrive. |
| `reschedule_interview` | `interviews:write` | `{ interview_id, scheduled_at, reason?, ignore_conflicts? }` | Change the scheduled_at time of an interview. Re-checks every attendee's free/busy (the interview's own calendar event never counts against its new time): a clash refuses with code "conflicts" naming who is busy; pass a suggested scheduled_at to retry, or ignore_conflicts: true only after the user explicitly says to move it anyway. |
| `reschedule_meeting` | `interviews:write` | `{ event_hint, new_starts_at, new_ends_at?, ignore_conflicts? }` | Move a previously booked meeting to a new time. Pass the user's phrasing (e.g. "Derren's meeting tomorrow") verbatim as event_hint - the matcher does the fuzzy work. If multiple meetings match, the user is asked which. A free/busy clash refuses with code "conflicts"; only re-send with ignore_conflicts: true after the user explicitly says to book anyway. |
| `resolve_identity_review` | `accounts:write` | `{ review_id, decision: accept \| reject }` | Decide a company or person match Lovelio was not sure about (Settings > Workspace > Review queue). Accept merges two company records into one organisation, or links a candidate and a client contact as one person; reject keeps them separate and the question is never asked again. |
| `resolve_occupation_review` | `accounts:write` | `{ title_norm, decision: accept \| dismiss, label?, isco_code? }` | Decide the occupation behind a job title Lovelio could not place on its own - a rank with no function like "Director", or a title two models disagreed about. Accept records the occupation for everyone holding that title; dismiss records that the title does not map to one occupation, and it is never asked again. |
| `resolve_review_item` | `accounts:write` | `{ review_item_id \| review_item_ids, resolved?, snoozed_until? }` | Mark one or more review queue items resolved or snooze them until a future date. |
| `respond_to_marketplace_intro` | `marketplace:write` | `{ intro_id, intro_action: check\|accept\|decline\|withdraw, decline_reason? }` | Act on an intro: check runs the prior-knowledge read (is this candidate already in the receiving database?) without revealing anything; accept forms the deal, locks the split, and reveals identity; decline passes with an optional reason; withdraw pulls back your own pending intro. |
| `revise_email_draft` | `emails:write` | `{ review_token, instruction }` | Apply a natural-language instruction ("make it warmer", "cut to two paragraphs") to a staged email draft. Returns the full updated subject + body. |
| `revise_job_draft` | `jobs:write` | `{ review_token, instruction }` | Apply a natural-language instruction ("make it punchier", "move to Sydney") to a draft. Returns the full updated draft. |
| `rewrite_job_ad` | `jobs:write` | `{ job_ad_id, instruction }` | Apply a natural-language instruction to rewrite an existing job ad using the company brand voice. |
| `run_report` | `analytics:read` | `{ report: weekly_bd_pulse \| quiet_clients \| cold_contacts \| fill_rate \| business_numbers \| revenue_analysis \| money_moments \| referee_details, confirm_cost? }` | Run a stored report. The Weekly pulse appears under Opportunities; quiet clients, contacts going cold, fill rate, agency numbers, revenue analysis, money moments, and referee details appear under Analytics. Money moments is one persons own copy and is never shared: an admin counts the whole agency, a team leader counts their team, a consultant counts only themself. The pulse and money moments also run automatically every Monday. Reports that spend money need confirm_cost: true. |
| `save_current_view` | `accounts:write` | `{ name, page ('jobs'\|'candidates'), query?, query_params? }` | Save the current search query as a named view. |
| `schedule_call` | `interviews:write` | `{ title, starts_at, ends_at?, candidate_id?, notes? }` | Schedule a phone call (with a candidate or internal). |
| `schedule_email` | `emails:write` | `{ candidate_id, subject, body, scheduled_at }` | Schedule an email to send at a future time. |
| `schedule_interview` | `interviews:write` | `{ application_id, scheduled_at?, format?, flavour?, submission_id?, ignore_conflicts? }` | Schedule an interview for a candidate on an application. flavour is internal (agency screen, default) or client (candidate meets the client - moves the application to client_interview and can link to the submission it came from). Booking an exact time checks everyone's free/busy first: a clash refuses with code "conflicts" naming who is busy, plus alternative times. Only re-send with ignore_conflicts: true after the user explicitly says to book anyway. |
| `schedule_meeting` | `interviews:write` | `{ title, starts_at, ends_at?, attendees: [{ kind, id? \| email + name }], notes?, ignore_conflicts? }` | Schedule a meeting (generic calendar event). Attendees must be resolved records - pass { kind: "hiring_manager"\|"user"\|"candidate", id } from search, or { kind: "external", email, name } for anyone outside the workspace. A free/busy clash refuses with code "conflicts"; only re-send with ignore_conflicts: true after the user explicitly says to book anyway. |
| `send_ai_voice_screen` | `applications:write` | `{ application_id }` | Invite a candidate to a short AI voice screening conversation. Emails them a no-login link; the transcript and per-question scores are saved on the application when they finish. Re-sends the existing link if one is still open. |
| `send_email` | `emails:write` | `{ candidate_id, subject, body, job_id? }` | Send an email to a candidate now. |
| `send_form` | `forms:write` | `{ form_id? \| activity?, client_contact_id? \| candidate_id, application_id?, interview_id?, job_id? }` | Email any form to a client contact or a candidate via a secure link - interview feedback, onboarding, background checks, or any form built in Settings > Forms. Lovelio follows up with non-responders and the answers are saved on the record. Pass form_id, or activity for a sendable slot (client_feedback_email, candidate_feedback_email). |
| `send_interview_followup` | `interviews:write` | `{ interview_id, recipient: "candidate"\|"client", email_subject, email_body }` | Send a post-interview follow-up: recipient "candidate" for the debrief / news / no-show note, recipient "client" for the next-steps or feedback note to the client contact (client interviews only). Always consultant-approved copy - never sent automatically. |
| `send_marketplace_listing` | `marketplace:write` | `{ listing_id, recipient_email, recipient_name?, recipient_agency?, message? }` | Send one of your own live marketplace listings to a partner agency that is NOT on Lovelio, as a tokenised link. The partner sees only the anonymous listing plus the declared split, and answers interested or not with one click, without an account. You get their response as a notification with their contact details; the deal itself runs off-platform. |
| `send_placement_email` | `placements:write` | `{ placement_id, recipient: "candidate"\|"client", email_subject, email_body }` | Send a placement lifecycle check-in: recipient "candidate" for the start-day good-luck note, recipient "client" for the guarantee-end check-in. Always consultant-approved copy - never sent automatically. |
| `send_referee_questionnaires` | `forms:write` | `{ application_id, referee_ids?, remind? }` | Email the reference form to every referee the candidate submitted for an application, in one go. Referees who already have one are skipped; optionally restrict with referee_ids. Pass remind: true to instead send a follow-up reminder to every referee whose form is still outstanding, on their existing link. |
| `send_reference_request` | `forms:write` | `{ application_id, referee_id, form_id? }` | Create a reference check form instance for a referee and send it to them. |
| `send_references_to_client` | `forms:write` | `{ application_id, reference_ids?, client_contact_id?, email_subject?, greeting?, email_body? }` | Send a candidate's completed references to the job's client contact as a secure no-login link, one link per referee, showing every question and the referee's own answer. Only references that are back can be sent, and a referee who said we may not quote them to the client is always left out. Optionally restrict with reference_ids. |
| `send_screening_form` | `forms:write` | `{ application_id, form_id? }` | Create a screening form instance for an application and send it to the candidate. |
| `send_spec` | `submissions:write` | `{ candidate_id, client_id, client_contact_id, headline, pitch, chips?: [...], why_client?, email_subject, email_body, attach_terms?, candidate_aware? } or batch { candidates: [{ candidate_id, headline, pitch, chips?, why_client?, candidate_aware? }] (max 5), client_id, client_contact_id, email_subject, email_body, attach_terms? }` | Send a spec (float in AU/NZ): candidates pitched to a client with no open job. The client contact gets ONE short personal email with a link to each fully anonymous profile page, where they respond one-click with no login, and the agency terms of business attached. Up to 5 candidates per send; each keeps its own spec record so the client responds per person. Client, contact, and candidates must exist. Never sent without consultant-approved copy. |
| `set_ai_screening` | `jobs:write` | `{ job_id, enabled?, focus?: string[] }` | Turn automatic AI voice screening on or off for a job, and set what the screen should focus on (a short list of focus areas). When on, every inbound applicant whose AI verdict is yes or maybe is invited automatically; candidates a consultant adds by hand are never auto-invited. |
| `set_candidate_cv` | `documents:write` | `{ candidate_id, document_id }` | Make one of a candidate's documents their current CV - the file the CV viewer, the resume parser and the API all read. |
| `set_client_fee_terms` | `clients:write` | `{ client_id, terms?: { currency, fee_basis, tiers, minimum_fee?, guarantee_days?, guarantee_remedy, guarantee_notes?, payment_terms_days? }, revert? }` | Set the fee terms for a client (fee percent or bands, minimum fee, guarantee, payment terms), or return them to the agency standard with revert: true. Admin-only: client terms are the one source of truth for fees and every job under the client inherits them. |
| `set_placement_credits` | `placements:write` | `{ placement_id, credits: [{ user_id, percent, role? }] }` | Set who gets credit for a placement: one line per consultant with a percent (must total 100) and an optional role (candidate_consultant, job_owner, business_development, other). Credit share is the consultant's billing figure everywhere. |
| `set_quota_target` | `accounts:write` | `{ scope: agency\|team\|user, amount\|null, period_start?, team_id?\|team_name?, user_id?\|user_email? }` | Set (or clear, with amount null) a billings target for a quota period: the whole agency (scope agency), a team (scope team, by team_id or team_name), or a consultant (scope user, by user_id or user_email). Defaults to the current period; pass period_start (YYYY-MM-DD, any date inside the period) for another one. Targets are optional at every level. |
| `share_candidate_to_marketplace` | `marketplace:write` | `{ candidate_id, headline, pitch, chips?: [...], location?, owner_share_percent: 50\|60\|70\|80, candidate_aware: true, accept_terms? }` | Put an anonymised candidate on the cross-agency split marketplace so other Lovelio agencies can request them for their clients. The candidate is never named until an intro is accepted, and must know they are being represented (candidate_aware is required). The listing declares the fee split and runs for 30 days. |
| `share_job_to_marketplace` | `marketplace:write` | `{ job_id, headline, pitch, chips?: [...], location?, salary_min?, salary_max?, currency?, fee_percent?, owner_share_percent: 50\|60\|70\|80, accept_terms? }` | Put an anonymised role on the cross-agency split marketplace so other Lovelio agencies can submit candidates. The client is never named; the posting agency is. The listing declares the fee split (owner keeps 50/60/70/80 percent) and runs for 30 days. Copy is consultant-approved, scrubbed server-side, and moderated before going live. |
| `skip_job_draft_gap` | `jobs:write` | `{ review_token, field }` | Record that the user opted to leave a gap blank. (The confirm step still refuses mandatory gaps.) |
| `start_video_call` | `interviews:write` | `{ interview_id, skip_email? }` | Create a Lovelio video call room on an interview and email the candidate a no-login join link. The call is recorded (audio) and transcribed live; recording and transcript are saved on the interview. Re-uses the open link if one exists. |
| `submit_scorecard` | `interviews:write` | `{ interview_id, scores, recommendation, notes? }` | Submit an interview scorecard / feedback for an interview. |
| `submit_to_marketplace_job` | `marketplace:write` | `{ listing_id, candidate_id, headline, pitch, chips?: [...], note?, attested_relationship: true, accept_terms? }` | Put one of your candidates forward for another agency's marketplace job listing. The owner sees an anonymous profile and your agency's name; the candidate's identity reveals only if the owner accepts the intro, which locks the declared split into a deal. Requires the attestation that you have interviewed the candidate and they agreed to be represented. |
| `update_application` | `applications:write` | `{ application_id, patch: { source?, owner_id? } }` | Update source / owner on an application (not stage - use move_stage). |
| `update_bd_opportunity` | `clients:write` | `{ opportunity_id, status: "new" \| "seen" \| "handled" \| "dismissed" }` | Mark a grounded public BD opportunity as seen, handled, not useful, or new again. This changes workflow state only. It never edits the source fact or contacts the company. |
| `update_bd_patch` | `clients:write` | `{ draft: true } or { statement }, then { apply: true, statement, profile }` | Save the agency's own description of what they recruit (roles, regions, seniority, anything they never touch). Call with { statement } first - it returns data.preview with the AI's reading played back, no write. Then call again with { apply: true, statement, profile: preview.profile } to save. For an agency that has not described anything yet, call { draft: true } instead: it reads the enrichment already in the account and returns data.draft with role and sector chips to remove plus three territories (city, region, country) to choose from, no write. Removing a SECTOR chip states an exclusion, which removes those employers from the map - that is how exclusions get stated at all, since no agency publishes what it will not do. Removing a ROLE chip only leaves it undeclared, so Lovelio stops sweeping the boards for it without blocking any employer. The patch shapes the BD map: declared desks count as fit, exclusions remove targets. |
| `update_brand_voice` | `accounts:write` | `{ prompt }` | Update the company's brand voice prompt used by every AI email/job ad generation. |
| `update_candidate` | `candidates:write` | `{ candidate_id, patch: { name?, email?, phone?, location?, linkedin_url?, skills?, current_employer?, current_job_title?, rating?, open_to_work?, do_not_approach? } }` | Update fields on a candidate (name, email, phone, current_employer, current_job_title, rating 1-5, open_to_work, do_not_approach, etc.). |
| `update_candidate_draft` | `candidates:write` | `{ review_token, name?, email?, phone?, linkedin_url?, location?, current_title?, current_company? }` | Patch a staged candidate draft (name / email / phone / linkedin_url / location / current_title / current_company). Re-computes gaps. |
| `update_candidate_preferences` | `candidates:write` | `{ candidate_id, patch: { preferred_roles?, preferred_seniority?, work_types?, open_to_relocation?, available_from?, preferred_locations?, salary_expectation?, notice_period?, work_rights? } }` | Update what a candidate WANTS: preferred roles, preferred locations (with relocation flag), work types (permanent/contract/temp/part_time), seniority preference, salary expectation, notice period / available-from date, work rights. |
| `update_candidate_tags` | `candidates:write` | `{ candidate_id, tags?: string[] } or { candidate_id, add?: string[], remove?: string[] }` | Put flat labels on a candidate. Send tags to replace the whole list, or add / remove to change it without knowing the rest. |
| `update_client` | `clients:write` | `{ client_id, patch: { name?, website?, phone?, industry?, description?, locations?, address?, notes? }, only_if_empty? }` | Update fields on a client (name, website, company phone, industry, locations, address, notes). Fee terms are NOT patchable here - set them with set_client_fee_terms. Pass only_if_empty with a single-field patch to fill a blank field without ever overwriting what is already there. |
| `update_client_contact` | `clients:write` | `{ contact_id, patch: { name?, email?, phone?, title?, reports_to?, is_primary?, notes? } }` | Update a contact on a client. |
| `update_distribution_rule` | `jobs:write` | `{ rule_id, patch: { name?, target_board_slugs?, filter?, active? } }` | Update a distribution rule (name, target boards, filter, or active state). |
| `update_email_draft` | `emails:write` | `{ review_token, subject?, body?, schedule_at?, template_chip? }` | Patch a staged email draft (subject / body / schedule_at / template_chip). Re-computes gaps. |
| `update_interview_action_draft` | `interviews:write` | `{ review_token, new_scheduled_at?, reason?, notify_candidate? }` | Patch a staged reschedule / cancel draft (new_scheduled_at / reason / notify_candidate). Re-computes gaps. |
| `update_interview_draft` | `interviews:write` | `{ review_token, scheduled_at?, format?, interviewer_id?, duration_min?, location?, notes? }` | Patch a staged interview draft (scheduled_at / format / interviewer_id / duration_min / location / notes). Re-computes gaps. |
| `update_interview_form_draft` | `interviews:write` | `{ instance_id, section_id?, section_score?, section_notes?, recommendation?, feedback? }` | Patch a section score / notes, or set the overall recommendation / final feedback on an interview form. |
| `update_interview_outcome` | `interviews:write` | `{ interview_id, outcome, feedback? }` | Record the outcome (Passed / Failed / No Show) of an interview. Never moves the application - how the interview went and what happens next are separate decisions, and a human makes the second one. |
| `update_job` | `jobs:write` | `{ job_id, patch: { title?, location?, status?, default_interview_format?, ... } }` | Update fields on a job (title, location, salary, default interview format, etc.). |
| `update_job_ad` | `jobs:write` | `{ job_ad_id, title?, description?, published? }` | Edit the title / description / published flag of a job ad. |
| `update_job_draft` | `jobs:write` | `{ review_token, updates: { title?, client_id?, team_id?, hiring_manager_id?, location?, compensation?, employment_type? } }` | Fill one or more gaps on a streaming draft. Each answer re-writes the summary via Haiku so the draft feels coherent. |
| `update_marketplace_deal` | `marketplace:write` | `{ deal_id, deal_action: add_to_pipeline\|mark_paid\|mark_received\|close, job_id?, reason? }` | Manage a formed marketplace deal. add_to_pipeline turns the revealed candidate into a real candidate + application on one of your jobs and links the deal so its status tracks your pipeline (the way to link candidate-listing deals, where you pick the job). mark_paid/mark_received is the handshake on a placed deal: the placing agency marks the partner share paid; the receiving agency confirms it arrived. close ends an unplaced deal with a required reason the other agency reads (a placed deal never closes this way). Facts both sides see on the deal record - Lovelio never moves the money. |
| `update_marketplace_listing` | `marketplace:write` | `{ listing_id, listing_action: pause\|resume\|withdraw\|filled\|renew }` | Pause, resume, withdraw, mark filled, or renew one of your marketplace listings. Renewing re-attests the listing is still real and extends it 30 days - expired listings never linger. |
| `update_phone_screen_draft` | `applications:write` | `{ review_token, outcome?, notes?, call_at?, duration_min? }` | Patch a staged phone-screen draft (outcome / notes / call_at / duration_min). Re-computes gaps. |
| `update_placement` | `placements:write` | `{ placement_id, patch: { salary?, currency?, fee_type?, fee_percent?, fee_amount?, start_date?, guarantee_days?, owner_id?, notes?, placement_type?, offer_date?, contract_end_date?, charge_rate?, pay_rate?, rate_period?, expected_gp? } }` | Update fields on a placement (salary, fee, start date, guarantee, owner, notes, contract rates and term, expected GP). |
| `update_scorecard_draft` | `interviews:write` | `{ review_token, scores?, criterion_id?, score?, note?, overall_recommendation?, feedback? }` | Patch a staged scorecard draft (per-criterion score / note, overall_recommendation, feedback). Accepts either a full scores array replacement or a single { criterion_id, score?, note? } patch. |
| `update_social_draft` | `jobs:write` | `{ job_id, linkedin?, twitter? }` | Edit a job's LinkedIn and X post text. |
| `update_workflow_rules` | `accounts:write` | `{ instruction } then { apply: true, changes: [{ field, to }] }` | Change workflow rule settings in plain English. Call with { instruction } first - it returns data.preview of the exact changes without saving. Then call again with { apply: true, changes: data.preview } to save. Never apply without showing the user the preview. |
| `upgrade_plan` | `accounts:write` | `{}` | Show the user the upgrade link for billing. |
| `withdraw_application` | `applications:write` | `{ application_id, reason? }` | Record that a candidate has withdrawn from a role. |

Operation ID: `executeBatch`

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `operations` | array of object | yes |  |
| `operations[].id` | string | no | Optional client-supplied correlation ID returned on the matching response op. A server ID is minted if omitted. |
| `operations[].op` | string (`add_client_contact`, `add_client_dna_note`, `add_client_recruiter`, `add_note`, `add_quota_adjustment`, `add_referee`, `add_to_talent_pool`, `answer_spec_question`, `answer_submission_question`, `approve_join_request`, `ask_analytics`, `ask_bd_target`, `ask_candidate`, `ask_client`, `ask_job`, `bulk_add_to_talent_pool`, `bulk_reject`, `bulk_send_email`, `cancel_interview`, `cancel_meeting`, `cancel_scheduled_email`, `chase_spec`, `chase_submission`, `close_job`, `complete_assessment`, `confirm_candidate_draft`, `confirm_email_draft`, `confirm_interview_action_draft`, `confirm_interview_draft`, `confirm_interview_form_draft`, `confirm_job_draft`, `confirm_phone_screen_draft`, `confirm_scorecard_draft`, `convert_bd_lead`, `convert_bd_target`, `convert_spec`, `create_application`, `create_candidate`, `create_client`, `create_distribution_rule`, `create_job`, `create_job_ad`, `create_placement`, `create_reminder`, `create_submission`, `create_talent_pool`, `create_webhook`, `decline_join_request`, `delete_candidate`, `delete_client`, `delete_client_contact`, `delete_document`, `delete_job`, `delete_talent_pool`, `delete_webhook`, `describe_a_job`, `dismiss_bd_lead`, `dismiss_bd_target`, `email_client_contact`, `extract_candidate_preferences`, `get_candidate_draft`, `get_email_draft`, `get_interview_action_draft`, `get_interview_draft`, `get_interview_form_draft`, `get_job_creation_status`, `get_job_draft`, `get_phone_screen_draft`, `get_scorecard_draft`, `invite_team_member`, `list_documents`, `log_bd_target_touch`, `log_call`, `log_offer`, `mark_placement_status`, `match_candidates_to_job`, `merge_candidates`, `move_stage`, `plan_linkedin_sourcing`, `prepare_cancel_interview`, `prepare_candidate_from_cv`, `prepare_compose_email`, `prepare_interview_form`, `prepare_phone_screen`, `prepare_reschedule_interview`, `prepare_schedule_interview`, `prepare_screening_form`, `prepare_submit_scorecard`, `promote_client_contact`, `propose_interview_slots`, `pursue_bd_target`, `reassess_application`, `reassign_record`, `record_client_verdict`, `reject_candidate`, `remove_client_recruiter`, `remove_from_talent_pool`, `reopen_job`, `request_interview`, `request_marketplace_candidate`, `request_referee_details`, `reschedule_interview`, `reschedule_meeting`, `resolve_identity_review`, `resolve_occupation_review`, `resolve_review_item`, `respond_to_marketplace_intro`, `revise_email_draft`, `revise_job_draft`, `rewrite_job_ad`, `run_report`, `save_current_view`, `schedule_call`, `schedule_email`, `schedule_interview`, `schedule_meeting`, `send_ai_voice_screen`, `send_email`, `send_form`, `send_interview_followup`, `send_marketplace_listing`, `send_placement_email`, `send_referee_questionnaires`, `send_reference_request`, `send_references_to_client`, `send_screening_form`, `send_spec`, `set_ai_screening`, `set_candidate_cv`, `set_client_fee_terms`, `set_placement_credits`, `set_quota_target`, `share_candidate_to_marketplace`, `share_job_to_marketplace`, `skip_job_draft_gap`, `start_video_call`, `submit_scorecard`, `submit_to_marketplace_job`, `update_application`, `update_bd_opportunity`, `update_bd_patch`, `update_brand_voice`, `update_candidate`, `update_candidate_draft`, `update_candidate_preferences`, `update_candidate_tags`, `update_client`, `update_client_contact`, `update_distribution_rule`, `update_email_draft`, `update_interview_action_draft`, `update_interview_draft`, `update_interview_form_draft`, `update_interview_outcome`, `update_job`, `update_job_ad`, `update_job_draft`, `update_marketplace_deal`, `update_marketplace_listing`, `update_phone_screen_draft`, `update_placement`, `update_scorecard_draft`, `update_social_draft`, `update_workflow_rules`, `upgrade_plan`, `withdraw_application`) | yes | Canonical action name. The generated catalogue above gives the scope, payload hint, and behaviour for every accepted value. |
| `operations[].payload` | object | yes | Action-specific payload. Use the payload hint in the generated catalogue above. Send the same typed external ids (job_, cnd_, app_, cli_, sub_, pla_) the rest of the API uses; the batch boundary decodes them before dispatch. |
| `operations[].idempotency_key` | string | yes | Per-operation idempotency key. Replaying the same (company, action, key) within 24h returns the cached result without re-executing. |

**Responses**

| Code | Description | Body |
| --- | --- | --- |
| `200` | HTTP 200 is returned when the batch itself succeeded. Per-operation status is inside `data.operations` - check each op status before treating the batch as a success. | { success, data: object, meta } |
| `400` | Batch exceeds 100 operations (error code: BATCH_TOO_LARGE). |  |
| `422` | Malformed batch body (missing operations, invalid JSON). |  |
| `429` | Rate limit exceeded. The batch is rejected without partially consuming rate limit. |  |

## Schemas

Objects referenced by the operations above.

### Meta

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `request_id` | string | no |  |
| `count` | integer | no |  |
| `has_more` | boolean | no |  |
| `next_cursor` | string \| null | no |  |
