Kairon
从购买信号构建潜在客户列表,丰富这些列表,并开展领英外展活动。
文档
Getting started
- Step 1
Create a Kairon account
Sign up and finish onboarding. It takes about 10 minutes and you need an active plan. - Step 2
Connect a LinkedIn seat
In Kairon, go to Settings and then Connections. Every tool acts as that seat, so nothing works until there is one. - Step 3
Get your MCP server URL
It is the same for every account, and it is at the top of this page. You will paste it in the next step. - Step 4a
Connect with OAuth (Claude web and desktop)
Open Settings, then Connectors, then Add custom connector. Paste the server URL and connect. Claude sends you to the Kairon sign-in once to approve it. -
Connect from a terminal (Claude Code, Cursor, scripts)
Run either of these, then the same sign-in as above. The Kairon CLI runs the same tools from a script or a cron job; its commands are documented at /cli.claude mcp add --transport http kairon https://app.heykairon.com/mcp npm i -g @kairon/cli && kairon login - Step 5
Start asking
You never name a tool yourself. Describe what you want and the assistant picks the tools it needs.- Find agencies in Spain hiring an SDR this month and save them to a list called Q3.
- How is the Q3 campaign doing?
- Who answered me this week, and what did they say?
- Find agencies in Spain hiring an SDR this month and save them to a list called Q3.
Tools
Everything an assistant can do once it is connected. If something is not on this list, it cannot do it.
This list is generated from the server on every release, so it cannot fall behind. The text stays in English because that is what your assistant reads.
How many of the org's OWN leads a pipeline-audience rule selects, before any list is created or anyone is contacted. Returns matched (the total), byStage (that total split across the Pipeline columns) and recentlyContacted7d — how many of the matches heard from you in the last seven days, which is the number that tells you whether the rule is about to write to people mid-conversation. Costs nothing and changes nothing; run it, read the numbers, then pass the SAME rule to list_create as audience.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
audience required | object | The rule to count, exactly as list_create would take it. Nothing is created and no one is contacted. |
A page of a COMPANY page's own posts — what a competitor, a partner or a target account publishes under its own name, not what its people post. Address it with target — { type, value } (company URL, universal name, or provider id) — and page with cursor; one page is one metered action, and a target that is not already a provider id spends one company.fetch to resolve first. For a PERSON's feed use profile_posts; to reach the people who engaged with one of these posts use post_interactions. Transient — nothing here is persisted.
| Name | Type | What it is for |
|---|---|---|
target required | object | The company page whose posts to read — named by URL, universal name, or provider id. |
cursor | string |
Sends these leads to the CRM the organization connected (HubSpot), whatever their Pipeline stage: each lands as a contact with its company, and its LinkedIn messages, emails, invites, likes and comments land on the contact's timeline. Queued, not instant — it lands within a minute or two. Pushing a lead already in the CRM is safe: only what the CRM does not have yet is sent. Answers CRM_NOT_CONNECTED when the organization has no CRM, and CRM_CONNECTION_BROKEN when an admin must reconnect it in Settings. Leads of other organizations, or deleted ones, are skipped and not counted in queued.
| Name | Type | What it is for |
|---|---|---|
leadIds required | string[] | The leads to send (lead ids, lead_…), at most 1000. |
Forbid contact with 1..1000 people or companies. This is not only about the future: everything already lined up for them stops. Their campaign runs end, messages already drafted or scheduled for them are dropped, and they are removed from every list holding them. Removing the entry later does NOT put them back in those lists. Set facet, then send items. A PERSON needs a LinkedIn profile URL, or a complete name (fullName, or firstName + lastName) TOGETHER with a company (companyName, companyUrl or companyDomain) — a name with no company is refused. A COMPANY needs at least one of companyName, linkedinUrl or domain. Every item is judged on its own and comes back added, duplicate or invalid with a reason: one bad item never fails the others.
| Name | Type | What it is for |
|---|---|---|
facet required | "people" | "companies" | What these items are. people suppresses each named individual. companies suppresses the company itself AND everyone who works there. |
items required | object[] | The entries to add, up to 1000. Each is judged on its own, so one invalid item never fails the rest. |
Who this organization must never contact — the org-wide suppression list, in two facets: people (named individuals) and companies (the company itself AND everyone who works there). Returns both, newest first, unless you name a facet. Read it before building a list or launching a campaign, and read it FIRST when asked why somebody is missing from a list or why a campaign skipped them: a person can be suppressed by their employer's entry, not only by their own. Each entry carries the id exclusion_remove takes.
| Name | Type | What it is for |
|---|---|---|
facet | "people" | "companies" | Read one facet only. Omit to get both, which is what "is this person blocked?" needs — someone can be suppressed by their employer's entry rather than by their own. |
Stop suppressing one person or one company, by the entry id exclusion_list returned (exper_… on people, excom_… on companies). It lifts the ban going FORWARD only: it does not put the person or the company back into any list they were swept out of, and it does not revive a campaign run that ended — add them to a list again if that is what you want. It removes ONE entry, so somebody suppressed by their employer's company entry stays suppressed until that entry goes too. An id of the wrong kind is refused; an id belonging to another organization is simply not found.
| Name | Type | What it is for |
|---|---|---|
facet required | "people" | "companies" | Which facet the entry sits on. It decides the id prefix: people → exper_…, companies → excom_…. |
id required | string | The entry id, exactly as exclusion_list returned it. |
Resolve a human-readable Sales Navigator filter value (location, industry, function) to a LinkedIn id, echoing the canonical label so a wrong match is visible. Cached globally; pass the returned urn to search_sales_navigator, or let it resolve names for you.
| Name | Type | What it is for |
|---|---|---|
type required | "location" | "industry" | "function" | "current_company" | "past_company" | "company_location" | "school" | "past_role" | "groups" | "persona" | "technologies" | "account_lists" | "lead_lists" | "postal_code" | Which Sales Navigator dimension the name belongs to. |
query required | string | The human-readable NAME to resolve, e.g. "San Francisco Bay Area". Never a LinkedIn id — resolving one is what this is for, and the canonical label comes back so a wrong match is visible. |
The members of a LinkedIn group, from the group’s own member list — people who joined a community around a topic, which is a targeting fact no search filter can express. Pass the group URL or id. Always runs on YOUR OWN seat: a public group needs no Sales Navigator and no membership; a members-only group works only if your seat has joined it, and is otherwise refused with that reason. When your seat’s read budget is spent it refuses with a try-later. Every page reports total; 100 members per page, one page per metered action. LinkedIn serves at most the 10,000 most recent joiners of a group, newest first — for a bigger group that is the slice you get, and the walk stops there. Each member carries networkDistance (1, 2 or 3) so you know who can be messaged and who must be invited first.
| Name | Type | What it is for |
|---|---|---|
group required | string | The group, as its URL (https://www.linkedin.com/groups/<id>/) or its bare numeric id. Read on your own seat: a public group needs no membership and no Sales Navigator; a members-only group needs your seat to have joined it. |
cursor | string | Continue from a previous page. Omit for the first page. |
count | integer | Members per page (default 50, max 100). LinkedIn refuses anything higher. |
intoList | string | Append this page’s members to one of your lead lists, by name or id. Combine with exhaust to walk the group into it without any of it crossing your context. |
exhaust | boolean | Page server-side until the results run out, instead of returning a cursor for you to send back. Requires intoList: every page is appended there and NO hits come back, only the totals — which is the point, since neither the hits nor the cursor then cross your context. Stops at maxResults and hands back a cursor to resume from. Each page still costs one metered action. |
maxResults | integer | How many hits an exhaust run collects before it stops (default 500, max 2000). Ignored without exhaust. Raise it when you know the audience is large and you mean to spend the actions. |
Create an ICP — a target thesis spanning the companies to reach and the people inside them — as an UNPUBLISHED DRAFT: nothing can qualify against it, and no list or campaign can pick it, until icp_publish makes it version 1. Criteria are optional: a name plus an empty definition is a valid coarse ICP you can sharpen later with icp_update. **Read the icp-definition skill first**: it carries the check rules and the filter defaults, and an ICP written without them disqualifies everyone while looking right. Each AI check is ONE yes/no question about ONE thing public data shows, at most 250 characters — write several short checks, never one long one; a longer check is refused.
| Name | Type | What it is for |
|---|---|---|
name required | string | A short, specific name — the segment, not the pitch. |
definition required | object | The ICP definition. On icp_update a field you omit is CARRIED FORWARD — send only what you are changing. A field you send replaces that field WHOLE. To clear: null for the thesis or a side, [] for a check list. AI CHECKS: several short ones, each ONE yes/no question public data answers, ≤250 chars. One nobody can answer comes back unconfirmed and disqualifies, emptying the ICP. Read the icp-definition skill. |
Add or remove values inside ONE filter list of one criteria side of the ICP's DRAFT, carrying the whole rest of the ICP forward. Like icp_update, nothing goes live until icp_publish. Use this instead of icp_update whenever you are changing a few values in a long list — icp_update replaces a criteria side WHOLE, so adding one excluded title there means resending every other title beside it. Lists — people: role.include, role.exclude, seniority.include, seniority.exclude, companyHeadcount, filters; company: headcount, filters. Values use the same names and shapes as search_sales_navigator. Adding a value already there, or removing one that is not, is reported back rather than refused — and if nothing changed, the draft is not written. Read the result change to see what actually happened.
| Name | Type | What it is for |
|---|---|---|
icpId required | string | |
side required | "company" | "people" | Which criteria side to edit: the company search or the people search. |
list required | "role.include" | "role.exclude" | "seniority.include" | "seniority.exclude" | "companyHeadcount" | "filters" | "headcount" | Which list inside that side. People: role.include, role.exclude, seniority.include, seniority.exclude, companyHeadcount, filters. Company: headcount, filters. A list the side does not have is refused by name. |
add | string | object[] | Values to append. Strings for every list but filters, which takes { type, query, mode? }. A value already there is left alone, never duplicated. |
remove | string | object[] | Values to drop, matched exactly (a filters item on type + query + mode). A value that is not there is reported back, not an error. |
Soft-delete an ICP. Returns referencedByCampaigns — how many campaigns referenced it as their enrollment gate (any status, draft included) and no longer have one — and referencedBySources — how many signal-based lists (paused included) targeted it. The delete always proceeds, so tell the operator when either is not zero: any of those campaigns that is running just widened its audience, and each of those lists now fails every daily run until source_set gives it another ICP.
| Name | Type | What it is for |
|---|---|---|
icpId required | string |
Read one ICP by icpId: its status, its live version (what everything qualifies against; null until the first publish), its draft (unpublished edits; null when there are none) and changes — what publishing the draft would change. Show the operator changes before you call icp_publish.
| Name | Type | What it is for |
|---|---|---|
icpId required | string |
List this org's ICPs, unpublished ones included. Each has a status — draft (never published: nothing can use it yet), published, or published_with_draft (live, with unpublished edits) — its live version, its draft, and changes: what the draft changes against the live version.
Takes no parameters.
Make the ICP's draft its next live version. From the next qualification on, every list and campaign that uses this ICP judges new leads against it (leads already judged keep their result). Publish changes who campaigns enroll and spends allowance on re-checks, so FIRST show the operator what changes (icp_get → changes, and how many campaigns use it) and publish only once they say yes. Returns the new version, the changes it made and usedByCampaigns. Refused when there is no draft.
| Name | Type | What it is for |
|---|---|---|
icpId required | string | |
ifDraftUpdatedAt | string | The draft.updatedAt you showed the operator. Publish then refuses (ICP_DRAFT_CHANGED) if the draft changed after, instead of publishing edits they did not see. |
Edit an ICP's DRAFT. Nothing changes who qualifies until icp_publish — so after editing, show the operator the changes in the result and publish only once they say yes. Send ONLY the fields you are changing — anything you leave out is carried forward from the draft (or the live version when there is no draft), including name. A field you DO send replaces that field whole, so a criteria side or a check list must arrive complete. To clear: null for the thesis or a side, [] for a check list. definition itself is always required — to rename and nothing else, send definition: {} (a rename applies at once; it is not part of the draft). **Read the icp-definition skill** before you change the AI checks — it decides whether the new version still qualifies anyone. A new or edited AI check over 250 characters is refused: rewrite or split it into short one-thing checks, never append.
| Name | Type | What it is for |
|---|---|---|
icpId required | string | |
name | string | A short, specific name — the segment, not the pitch. |
definition required | object | The ICP definition. On icp_update a field you omit is CARRIED FORWARD — send only what you are changing. A field you send replaces that field WHOLE. To clear: null for the thesis or a side, [] for a check list. AI CHECKS: several short ones, each ONE yes/no question public data answers, ≤250 chars. One nobody can answer comes back unconfirmed and disqualifies, emptying the ICP. Read the icp-definition skill. |
Copy an earlier published version into the ICP's DRAFT — exactly like a campaign restore. Nothing changes who qualifies until icp_publish. It REPLACES any draft already there, so if icp_get shows a draft, confirm with the operator first. Read icp_versions to pick the right one.
| Name | Type | What it is for |
|---|---|---|
icpId required | string | |
version required | integer | The version to bring back, as icp_versions listed it. |
Every PUBLISHED version of this ICP, newest first, with the date, the check counts and changes against the version before — and which one is in use. Versions are minted only by icp_publish and never rewritten; unpublished edits live in the draft (icp_get). Pass version to get that one in full: its thesis, both filter sets, both check lists, as they were published.
| Name | Type | What it is for |
|---|---|---|
icpId required | string | |
version | integer | Omit for the list of versions. Give one to get THAT version in full. |
The connection requests waiting in your LinkedIn inbox — people who asked to connect with YOU and have not been answered. The one audience that came looking for the operator, and until now nothing read it. Each hit is one request: person (the inviter), note (what they wrote when they asked, verbatim — often the reason they want in) and receivedAt. Page with cursor; 100 per page, one page per metered action. exhaust walks every page into the list, and like every other walk it returns the counts rather than the rows — read a page at a time when you want to READ the notes. Read-only: this does not accept or decline anything.
| Name | Type | What it is for |
|---|---|---|
cursor | string | Continue from a previous page. Omit for the first page. |
count | integer | Invitations per page (default 50, max 100). |
intoList | string | Append the people who invited you to one of your lead lists, by name or id. Combine with exhaust to walk every pending request into it. |
exhaust | boolean | Page server-side until the results run out, instead of returning a cursor for you to send back. Requires intoList: every page is appended there and NO hits come back, only the totals — which is the point, since neither the hits nor the cursor then cross your context. Stops at maxResults and hands back a cursor to resume from. Each page still costs one metered action. |
maxResults | integer | How many hits an exhaust run collects before it stops (default 500, max 2000). Ignored without exhaust. Raise it when you know the audience is large and you mean to spend the actions. |
Store the work email a finder returned, on the PEOPLE themselves — which is what an email campaign reads, unlike a custom list column. { items, source }, ≤500 items. Name each person by memberId, url, or providerId, then give either email or notFound: true. source names the provider (clay, apollo, contactout, hunter, instantly), recorded per person so a bounce can be traced to it.
Read the full description
Send notFound: true for misses too, not just hits: it marks the person already-asked, the only thing that stops the next pass paying to ask again. list_members { emailState: 'unasked' } then returns exactly who is left. Per item: set, missed (asked, no address), refused, not_found (nobody by that name here — nothing is created), invalid. refused needs no retry: a human-typed address is never overwritten, and a notFound is ignored for anyone who already has one.
| Name | Type | What it is for |
|---|---|---|
items required | object[] | What was learned about each person, each naming its subject by ONE of memberId, url, or providerId, plus either an email or notFound: true. |
source required | "clay" | "apollo" | "contactout" | "hunter" | "instantly" | Which finder produced these answers. Recorded per lead, so a bounce can later be traced to the provider that supplied the address. A human typing an address in does not use this tool. |
Fetch a LinkedIn person OR company through the caller's connected account — whichever the target addresses ({ type, value }: url, public_identifier, or provider_id). The kind is detected from the target; pass kind: 'company' for one addressed by universal name or numeric id. Returns complete data, metered and cached (refresh: true forces a live read), plus headroom — how many MORE fetches you may make, across every limit. Budget a large audit against the smallest number in it, and keep going until nextSlotAt is set: only that field means wait. A counter at 0 does not — Kairon still serves the next call. For a person, withRecommendations: true also returns who vouched for them — each recommender named and addressable, the strongest warm-intro path LinkedIn publishes. When the person is a 1st-degree connection of this seat, relative.emails and relative.phones carry their contact panel — visible via the connection, never stored.
| Name | Type | What it is for |
|---|---|---|
target required | object | Who this acts on — one LinkedIn person or company, named by URL, vanity slug, or provider id. |
kind | "profile" | "company" | Override auto-detection. Required for a company addressed by universal name or numeric id. |
refresh | boolean | True bypasses the cache and forces a live read from LinkedIn. Slower, and it spends a lookup against your headroom. |
withRecommendations | boolean | True also returns who recommended this person (and who they recommended), with each recommender identified. Warm-intro paths. Always spends a live lookup — never served from cache. |
Add members to a list ({ asset, id, items }, up to 500). An item is { memberId } (already in the org), { url } (a pasted LinkedIn URL — instant and free, no provider call), or { snapshot } (a hit from search_people / search_sales_navigator / signal_search, passed as-is with its providerId, which is what makes the saved list show the person rather than a bare id). Re-adding a current member converges. This is how you assemble an audience after a search.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string | |
items required | object[] | The rows to add, each naming its subject by ONE of memberId, url, providerId, or snapshot. |
Create a named list — the campaign targeting unit. asset: 'leads' for people, asset: 'companies' for companies (build the company set first, then find people inside it by passing the list's id to search_sales_navigator as companyLists). Names are unique per org and asset, case-insensitive; a taken name fails with the existing list's id, never a silent merge. Two ways to fill it. Name the people yourself with list_add. Or pass an audience rule and the list fills itself from the org's OWN leads — Pipeline stage, tag, owner, ICP verdict, last contact, when they arrived — and keeps filling every night as more people match. Leads only. Run audience_preview on the rule first: it costs nothing and tells you how many it selects and how many of those you contacted this week.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
name required | string | The list's name, for the operator to recognise it. |
audience | object | Make this list build itself from a rule over the org's OWN leads, instead of adding people by hand. include selects; exclude (optional) removes from that selection; inside one condition the fields AND together and the values in a field OR together; a group's match is all (AND) or any (OR) across its conditions. The rule only ever ADDS: whoever matches becomes an ordinary member and stays one, so a campaign's own first message — which moves the person off on_hold — cannot cancel their sequence. It re-runs nightly, so people who reach the stage later join by themselves. TWO THINGS TO GET RIGHT. on_hold fills the instant a campaign's last message goes out, so pair it with exclude: { match: 'all', conditions: [{ lastContacted: '30d' }] } unless you really mean to write to someone who heard from you yesterday — in the orgs we measured, 17-45% of the On hold pile had been contacted inside 30 days. And there is no pause: clearing the rule with audience: null is the only way to stop it, and the members it already added stay. Use audience_preview first to see how many people the rule selects. |
Soft-delete a list by asset + id: it disappears from list_list with its memberships, the member leads/companies are untouched (they stay in the org pool, in their other lists, with their qualification state intact), and the name is immediately free for reuse.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string |
One list by asset + id: its name, live member count, created date, and its ordered columns (the list's own custom columns — free-text fields you attach to its members). Out-of-org or deleted ids are not found. To read who is IN it, use list_members, whose rows carry each member's values for those columns. A list that fills itself from a rule also returns that audience rule and the last run's trail — when it ran, how many it added, and the error if it failed. Custom list columns reach a message two ways: {{@columnName}} sends that column's value word for word, and a normal AI slot that mentions a column can use it as a fact, written in the writer's own words.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string |
Read this org's lists of one asset, keyset-paginated, newest first, each with its live member count. q matches the list name (case-insensitive). Page with the returned cursor. Deleted lists never appear.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
q | string | Case-insensitive substring of the list's name. |
cursor | string | |
limit | integer = 25 |
One keyset page of a list's members. Row: { id, title, subtitle, location, linkedinUrl, target, company, companyId, companyRecordId, email, values, tags, owner, qualification, employeeCount, currentTitle, crmStage } — the id removes it with list_remove or writes with list_set. Leads carry currentTitle (the job; subtitle is the headline) and crmStage (what your team DID); companies carry employeeCount. Pass target to linkedin_fetch AS-IS; linkedinUrl is null for Sales Nav members.
Read the full description
NARROW HERE — every filter below runs in SQL; never page a list and sift it yourself. qualification is only the verdict. includeQualification: true (both assets) adds qualificationDetail per row — criteria, rationales, evidence — which answers 'why were these disqualified' in ONE call. A filter or sort the asset lacks is REFUSED, never ignored. employeeCount is null when nobody has learned it, and null matches NEITHER size bound, so the halves never add up. Page with cursor.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string | |
q | string | Case-insensitive substring over the member's display fields. |
cursor | string | |
limit | integer = 25 | |
tags | string[] | Keep only members carrying EVERY tag named — an AND, not an OR. Matched case-insensitively; a name no tag has yields an empty page rather than an error. |
sort | "person" | "title" | "company" | "email" | "industry" | "qualification" | "owner" | "added" | "source" | Which column to order by. Leads: person, title, company, qualification, owner, added, source. Companies: company, industry, qualification, owner, added. Naming a field the asset does not have is refused. Omit for the default, newest-added first. |
direction | "asc" | "desc" | Order direction; read as asc once a sort field is named. |
qualificationStatus | string | string[] | Keep members whose ICP verdict is ANY OF these: qualified, disqualified, unevaluated. unevaluated means nobody has judged them — never that they failed. |
ownerId | string | string[] | Keep members owned by ANY OF these user ids. Ownership rides the entity, not the list. |
tagId | string | string[] | Keep members carrying ANY OF these tag ids — an OR, and the counterpart to tags above, which is an AND over NAMES. |
addedAfter | string | Keep members added to THIS list at or after this ISO timestamp. |
addedBefore | string | Keep members added to THIS list strictly before this ISO timestamp. |
hydrated | boolean | true keeps members whose LinkedIn facts have been loaded; false keeps the ones nobody has fetched yet (they have no size or HQ to filter on). |
column | object[] | Keep members matching EVERY predicate over this list's custom columns. A name the list does not have is refused, so a typo cannot read as 'no matches'. |
includeQualification | boolean | Attach each member's stored qualification scorecard — the same body qualification_get returns, so a half-rejected list explains itself in ONE call instead of one per member. This is how you answer 'why were these disqualified'. Works on both assets. Off by default because it is BIG — every criterion with its rationale and evidence — so pair it with a small limit (10-25) rather than a full page. |
signalKey | string | string[] | Leads only. Keep members that arrived from ANY OF these signals. |
crmStage | "new" | "invite_sent" | "connected" | "contacted" | "replied" | "interested" | "meeting_booked" | "won" | "on_hold" | "lost" | "discarded"[] | Leads only. Keep members standing at ANY OF these pipeline stages. |
emailState | "has" | "missing" | "unasked" | Leads only. Narrow by what is known about the person's work email: has (an address is on file), missing (a finder was already asked and came back empty — asking again costs money and learns nothing), or unasked (nobody has looked). unasked is the one an enrichment pass wants: it IS the remaining work, so a pass can stop, resume tomorrow, and never pay twice for the same person. |
employeeMin | integer | Companies only. Keep companies with AT LEAST this many employees. |
employeeMax | integer | Companies only. Keep companies with AT MOST this many employees. |
industry | string[] | Companies only. Keep companies carrying ANY OF these industries, matched case-insensitively against the whole industry list, not just the one shown as subtitle. |
hqCountry | string[] | Companies only. Keep companies whose HQ is in ANY OF these ISO-2 country codes. |
Judge a list's members against an ICP and return the run that started. Omit subjectIds to cover every live member; name them to qualify a subset.
Read the full description
Sample first: call with sample: 20 (Kairon picks members not judged yet) and read the scorecards. If the verdicts look wrong, propose an ICP fix to the operator and sample again. Then tell the operator how many qualified out of qualified + disqualified (not the total: alreadyCurrent were judged before) and whether you think that is good enough, and qualify the whole list only when they say so. Members already judged against this ICP's ACTIVE version are not judged again. It waits up to a minute: a run that ends in time comes back FINISHED, as list_qualify_runs shows it; a longer one comes back still going — follow it with list_qualify_runs. Ids it cannot judge come back in dropped with a reason. The list-building skill has the order to qualify in, and what to do when a run stops itself.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string | |
icpId required | string | The ICP to judge against. Its ACTIVE version is pinned when the run starts. |
subjectIds | string[] | The members to judge. Omit to cover every live member of the list. Ids that are no longer members are dropped, and the run reports what survived as its total. |
sample | integer | Judge a sample of this many members instead of the whole list — Kairon picks members with no verdict against this ICP version first. Ignored when subjectIds is given. |
ignoreIcpMismatch | boolean = false | Keep going past a stop you have already read. Kairon stops a run once disqualifications run well past its allowance, and says which criterion did the damage. This answers THAT stop: it takes effect only when this list's LAST run stopped that way against this same ICP version, and is otherwise ignored — the run still starts, and still stops if the list does not fit. One stop buys one override, so a list that keeps not fitting keeps stopping. Editing the ICP arms the guardrail again, which is what tells you whether your edit worked. |
Stop a run that is still going. Every verdict it already reached is KEPT — cancelling costs nothing and undoes nothing, it only stops the spending from here on. The members it never reached keep no verdict and stay unevaluated. Refused on a run that already finished, because answering "fine" to a brake that stopped nothing would report a saving that never happened.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string | |
runId required | string | The run to stop. |
How a run is going, or how it ended. Without runId, this list's latest run; with one, that run. null: never qualified — an answer, not a failure.
Read the full description
judged = paid to decide (qualified / disqualified); alreadyCurrent skipped free; failed could not be READ, stays unevaluated. criterionBreakdown (every finished run), per criterion: failed, unconfirmed (the fact was not there), examples, and a suggestion to act on first — missing_on_linkedin / enable_web_or_rewrite / rewrite_or_remove fix the ICP; narrow_the_search fixes the search; check_examples: show the operator the examples, ask if they wanted them. stopReason: allowance_exhausted — start another over the same members to resume. icp_mismatch — rejections ran past the allowance; the ICP OR the search may be wrong: read the suggestions, then the list-building skill. platform_outage — KAIRON's internal failure: nothing judged or spent, retry later, never tell them to top up.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string | |
runId | string | A specific run. Omit for this list's most recent one. |
Remove members from a list ({ asset, id, items }). Each item names ONE member however you hold it: { memberId }, { url }, { providerId }, or { snapshot } (a hit passed back unedited — only its providerId is read). Deletes only the membership; the leads/companies stay in the org pool and their other lists. Per item: removed, not_member, or not_found.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string | |
items required | object[] | The rows to remove, each named by any one identity the member could have been added with. |
Write custom-column values on members ALREADY in a list ({ asset, id, items }, ≤500). Each item names one member — { memberId }, { url }, { providerId } or { snapshot } — plus values, keyed by COLUMN NAME.
Read the full description
Merges: names you send are written, names you omit keep what they had, the empty string clears. Create columns first with list_update; a name this list lacks is skipped and echoed in ignored. Per item: set, not_member, not_found. Read them back with list_members. When a value is a fact you looked up rather than one the operator told you, put the source in the cell — the quote or number, the URL you read it on, and the date. Values the operator owns (a stage, a note, your own summary) need none of that. Custom list columns reach a message two ways: {{@columnName}} sends that column's value word for word, and a normal AI slot that mentions a column can use it as a fact, written in the writer's own words.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string | |
items required | object[] | The rows to write, each naming its subject plus the custom-column values to set on it. |
Rename a list and/or replace its custom columns ({ asset, id, name, columns? }). A name another live list of that asset holds fails with that list's id.
Read the full description
Custom columns are free-text fields on the members of THIS list, addressed by NAME. columns replaces the WHOLE ordered set: omit it to leave them alone, [] removes all. { name, renameFrom } renames one and keeps its values; without renameFrom the old column and everything in it is destroyed. Max 20, names ≤40 chars. Custom list columns reach a message two ways: {{@columnName}} sends that column's value word for word, and a normal AI slot that mentions a column can use it as a fact, written in the writer's own words. audience sets or clears the rule a list fills itself from (leads only). Sending one stores it AND runs it, so the reply carries what that run did. Omit the field to leave the current rule alone; send null to clear it — the members it already added stay, because clearing is the only way to stop it.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string | |
name required | string | The list's name. ALWAYS required here, even when you only mean to change columns — send the current name back to keep it. |
columns | object[] | The WHOLE ordered custom-column set, replacing what is there. Omit to leave the columns alone; [] removes them all. Carry renameFrom to rename a column and keep every member's values under the new name. |
audience | object | null | Make this list build itself from a rule over the org's OWN leads, instead of adding people by hand. include selects; exclude (optional) removes from that selection; inside one condition the fields AND together and the values in a field OR together; a group's match is all (AND) or any (OR) across its conditions. The rule only ever ADDS: whoever matches becomes an ordinary member and stays one, so a campaign's own first message — which moves the person off on_hold — cannot cancel their sequence. It re-runs nightly, so people who reach the stage later join by themselves. TWO THINGS TO GET RIGHT. on_hold fills the instant a campaign's last message goes out, so pair it with exclude: { match: 'all', conditions: [{ lastContacted: '30d' }] } unless you really mean to write to someone who heard from you yesterday — in the orgs we measured, 17-45% of the On hold pile had been contacted inside 30 days. And there is no pause: clearing the rule with audience: null is the only way to stop it, and the members it already added stay. Use audience_preview first to see how many people the rule selects. Omit this field to leave the current rule untouched; send null to clear it (the members it already added stay). |
Make one teammate responsible for the named leads or companies. Name them by email (what you usually have) or userId; pass owner: null to leave them unassigned. The owner must be a current member of the organization. This only labels — it grants no permission and does not decide which seat sends. Never creates a lead: an unknown url comes back not_found.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of row these items are — people or companies. |
items required | object[] | The rows to assign, each naming its subject by ONE of memberId, url, or providerId. |
owner required | object | null | Who to assign every item to — one org member, by userId or email. The same owner is set on all of them. |
Where every one of this org's people stands. Returns a count for each stage — new, invite_sent, connected, contacted, replied, interested, meeting_booked, won, on_hold, lost, discarded — and, when you name a stage, that column's people with their role, their owner, and when they were last contacted. EVERY lead is on this board, so new is where the ones nobody has acted on yet collect; the early columns then separate an invitation nobody accepted (invite_sent) from an accepted one nobody followed (connected) from a message that got no reply (contacted). Use lastContacted to ask about a window ("who at interested have we not touched in 30 days"). A column comes back one page at a time: hasMore says whether more remain, and passing the returned cursor back reads the next page. Reads only: it never moves anyone.
| Name | Type | What it is for |
|---|---|---|
stage | "new" | "invite_sent" | "connected" | "contacted" | "replied" | "interested" | "meeting_booked" | "won" | "on_hold" | "lost" | "discarded" | Return this column's people as well as the counts. Omit for counts only, which is what "how is the pipeline doing" needs. |
lastContacted | string | Narrow to people last contacted inside a window: 7d, 30d, 90d, never, or an explicit YYYY-MM-DD..YYYY-MM-DD. Narrows the counts as well as the people. |
cursor | string | Resume the named column from a previous call — pass the cursor it returned. |
limit | integer = 25 | How many people to return when stage is named. |
Record where people now stand: new, invite_sent, connected, contacted, replied, interested, meeting_booked, won, on_hold, lost, or discarded. Name them by url (what you usually have), memberId, or providerId. Moving someone backward is allowed. Kairon advances the early stages on its own from what actually happens — an invitation going out, them accepting it, a message going out, them writing back — so use this for the judgment calls it cannot make: a meeting booked, a deal won or lost, someone parked. Two stages are not rungs on the ladder: on_hold keeps someone and does nothing for now — Kairon also puts people there itself when a campaign runs out of things to say and they never replied — and discarded means never contact them again — it also adds them to the exclusion list, which ends any campaign they are in and drops messages already scheduled for them. Never creates a lead: an unknown url comes back not_found.
| Name | Type | What it is for |
|---|---|---|
items required | object[] | The people to move, each naming its subject by ONE of memberId, url, or providerId. |
stage required | "new" | "invite_sent" | "connected" | "contacted" | "replied" | "interested" | "meeting_booked" | "won" | "on_hold" | "lost" | "discarded" | Where they now stand. The ladder runs new → invite_sent → connected → contacted → replied → interested → meeting_booked → won | lost. new is where a lead nobody has acted on sits; invite_sent is an invitation not yet accepted; connected is accepted with nothing said yet; contacted means there is a message they can read. You may move someone backward as well as forward. Two stages sit outside the ladder, for people nobody is working: on_hold keeps them and does nothing for now, and discarded means never contact them again — which also adds them to the exclusion list, ending any campaign they are in. |
WHY one lead or company carries the verdict it does ({ asset, id }) — the stored scorecard. Ids come from list_members. Reads rows we hold: spends nothing, changes nothing.
Read the full description
Returns status, the tallies total / met / notMet / notChecked, and criteria — one { criterion, result } per criterion the ICP version defines — a filter's dimension / mode / values and the member's actual, an AI check's description and the judge's rationale + evidence. status: met, not_met (a fact says no) or unconfirmed (the fact is not there, disqualifies unless waived). result: null is NOT CHECKED: no failure, still disqualifies. On a lead, manualQualification (with manualQualificationBy) means a PERSON set status by hand; the tallies still show what the criteria found. Null means Kairon decided. icpVersionId is the version pinned when it was judged, not today's active one. An unevaluated member has no criteria — an answer, not a failure; next is list_qualify.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
id required | string | The member's id, exactly as list_members returned it — a lead id under asset: 'leads', a company id under asset: 'companies'. |
Say whether these people are worth reaching out to, in your own judgement, instead of waiting for Kairon to decide. qualified lets someone through even if your ideal-customer profile would reject them; disqualified keeps someone out even if it would accept them. Pass null to take your decision back. Name them by url, memberId, or providerId.
Read the full description
Your decision is what the lists show, what the filters match, and what the campaigns obey, for as long as it stands. Kairon keeps judging underneath it — qualification_get still shows what the criteria found, though its status now reads your decision — so taking your decision back hands the person straight back to what it had decided, with nothing to re-run and nothing to pay for. Use it for the calls Kairon cannot make: someone you know personally, someone whose profile does not say what you know about them, or a good lead you need in a campaign that leaves today. Never creates a lead: an unknown url comes back not_found.
| Name | Type | What it is for |
|---|---|---|
items required | object[] | The people to decide about, each naming its subject by ONE of memberId, url, or providerId. |
qualification required | "qualified" | "disqualified" | null | Your own verdict: qualified to say these people are worth reaching out to even if the ICP disagrees, disqualified to keep them out even if it agrees. Pass null to take your verdict back and let the ICP decide again. Your verdict wins for as long as it stands: it is what the lists show, what the filters match, and what the campaigns obey — while Kairon goes on judging underneath, so taking it back costs nothing and restores what the ICP had decided. |
Detach the source from a lead list. The list KEEPS every member the source already found — only the feed stops, so the list becomes an ordinary static list rather than losing anything. Frees the one-source-per-list slot, so a new source can be attached afterwards with source_set. To pause a source temporarily instead, source_set it to a paused status.
| Name | Type | What it is for |
|---|---|---|
listId required | string | The lead list whose source this addresses, from list_list or list_create. At most one source exists per list, so this is the source's address — there is no separate source id. |
The source attached to a lead list — the recurring feed that makes it a SIGNAL-BASED list, growing itself over time. Returns null when the list has none, which means it is an ordinary static list, not that anything failed. The result carries the ICP the source targets and every signal on it, each with its own id — those ids are what source_set echoes back to keep a signal's place in its feed. Read this before changing a source: source_set replaces the whole signal set.
| Name | Type | What it is for |
|---|---|---|
listId required | string | The lead list whose source this addresses, from list_list or list_create. At most one source exists per list, so this is the source's address — there is no separate source id. |
Run the source now instead of waiting for its daily run, and return the run that started. This SPENDS — it is a real provider search on the seat the source was created on, and it is the only tool here that costs anything. Attaching a source does not run it, so this is how a newly attached list first fills. Refused while the source is paused, or while one of its runs is already in flight; a run is not instant, so read what it did with source_runs rather than expecting members back from this call.
| Name | Type | What it is for |
|---|---|---|
listId required | string | The lead list whose source this addresses, from list_list or list_create. At most one source exists per list, so this is the source's address — there is no separate source id. |
What the source actually did. Without runId, one page of run history newest-first — when each run happened and how many people it appended. With runId, that run's CANDIDATES instead: one row per person it considered and the outcome that decided them, which is where "why is this person in my list, and why is that one not?" is answered. Page with the returned cursor; a run's candidates come back in the order the run touched them, so the page order IS the funnel order. Reads only — it never starts a run and spends nothing.
| Name | Type | What it is for |
|---|---|---|
listId required | string | The lead list whose source this addresses, from list_list or list_create. At most one source exists per list, so this is the source's address — there is no separate source id. |
runId | string | A run id from this tool's own summary page. Given, the result is THAT run's candidates — one row per person the run considered, with the outcome that decided them. Omitted, the result is the run history: when each ran, and how many it appended. |
limit | integer | Rows per page, 1-100. Defaults to 25. |
cursor | string | The cursor from the previous page. A cursor minted for the run history cannot be used against a run's candidates, or the other way round — a mismatched token restarts from the first page rather than paging the wrong thing. |
Make a lead list SIGNAL-BASED, or change the one it already is: give it an ICP to target and the signals that should feed it. This REPLACES the whole signal set — any signal you do not list is removed — so call source_get first and send back every signal you want to keep, each with the id it gave you. A kept signal holds its place in its feed; one sent without its id is treated as new and restarts from the top, which costs a re-walk. Attaching does NOT run the source: nothing is searched or spent until source_run. Swapping to a different ICP restarts every signal, because the audience changed. Refusals are total — a bad config or an unknown signal id leaves the source exactly as it was.
| Name | Type | What it is for |
|---|---|---|
listId required | string | The lead list whose source this addresses, from list_list or list_create. At most one source exists per list, so this is the source's address — there is no separate source id. |
icpId required | string | The ICP this source targets, from icp_list. Its ACTIVE version is both the audience searched and the gate every candidate must pass — so revising the ICP changes future runs. Swapping to a DIFFERENT ICP restarts every signal from the top of its feed, because the audience changed. |
signals required | object[] | The COMPLETE set of signals this source should carry — at least one. Any signal not listed here is removed. Echo each kept signal with the id source_get gave it, or it is treated as new and restarts from the top of its feed. |
status | "active" | "paused" | Whether the source keeps feeding the list. paused stops it without losing anything — the signals, their places in their feeds, and every member already found all survive, and resuming carries on where it left off. Use this rather than source_delete to stop a list growing for a while. Omitted leaves the current setting; a new source starts active. |
contactFilter | object | Who this list may add, by how much you have already talked to them — the same rule a campaign enrolls by. Omitted leaves the current setting; a new source adds everyone. Narrow it to stop a list filling up with people already in a conversation. |
acceptExtraSignalCharge | boolean | 3 active signals per seat are included in the plan; each one past that costs $10/month, billed to the organization. A call that would push the seat past its included count is REFUSED unless this is true — ask the user first, then retry with it. Ignored when the set fits within the included count. |
Removes the tag from the organization AND from every lead and company wearing it, in one act. Reports how many of each lost it. The name becomes free to use again immediately. This does not delete any lead or company.
| Name | Type | What it is for |
|---|---|---|
id required | string |
Without id, creates a tag. With id, renames and/or recolors that one — and a rename carries it across every lead and company already wearing it, in one act. Names are unique per organization, compared case-insensitively. Colors are palette names: stone, clay, moss, sky, plum, amber, rust, slate.
| Name | Type | What it is for |
|---|---|---|
id | string | |
name | string | The tag name. Required when creating; on an existing id it renames the tag everywhere. |
color | "stone" | "clay" | "moss" | "sky" | "plum" | "amber" | "rust" | "slate" | Display colour for the tag. Omit and one is chosen. |
Replaces each named subject's WHOLE tag set — so send every tag it should end up with, not just the new one, and send [] to clear it. Name a subject by memberId, url, or providerId, exactly as list_remove does. Tags are named by NAME and must already exist (tag_save first); an unknown name fails the call rather than creating one. This never creates a lead or a company: an unknown url comes back not_found.
| Name | Type | What it is for |
|---|---|---|
asset required | "leads" | "companies" | Which kind of row these items are — people or companies. |
items required | object[] | The rows to tag, each naming its subject by ONE of memberId, url, or providerId. |
Search, in plain words, for a third-party data endpoint Kairon can run for you — data OUTSIDE LinkedIn: tweets by handle, a Google Maps listing, a company's reviews, a person's record at a data broker, and hundreds more. Say what you want ("tweets by a handle", "amazon reviews for a product"), not a provider name. Each match carries a provider, an endpoint, a relevance score and the provider's price per call or per result. Free. Then webdata_inspect the match you like to learn its inputs.
| Name | Type | What it is for |
|---|---|---|
query required | string | What data you are after, in plain words. Up to 1000 characters. |
limit | integer | How many matches to return. Default 5, at most 40. |
The full card for one endpoint webdata_find returned, by its provider and endpoint: what it does, the exact input schema webdata_run expects (path, query and body parameters), the provider's price, a link to the provider's own docs, and any notes. Free. Read this BEFORE quoting an endpoint — the schema is what you must send, and the input you send decides the cost.
| Name | Type | What it is for |
|---|---|---|
provider required | string | The provider slug from webdata_find, e.g. apify. |
endpoint required | string | The endpoint path from webdata_find, e.g. /apidojo/tweet-scraper. |
What THIS organization will pay for one run of an endpoint with exactly this input — the provider's price plus Kairon's margin — and the quoteId that webdata_run requires. Per-call endpoints are quoted exactly. Per-result endpoints are quoted per result; pass expectedResults (the limit you set in input, or your honest estimate) to get an estimatedUsd. THAT TOTAL IS AN ESTIMATE, NOT A PRICE: say it to the operator as approximate ('about $0.60'), never as the exact amount, because the provider bills what actually comes back — more or fewer results than you expected. An endpoint that states no price comes back with quoteId: null and CANNOT be run — say so and pick another. Free. YOU MUST show the operator the quoted price in plain words and get their yes BEFORE calling webdata_run — the run refuses without a quote, and a quote is only valid for 30 minutes and for this exact input. Quote it against allowance_get: what they hold, what this takes, what it leaves.
| Name | Type | What it is for |
|---|---|---|
provider required | string | The provider slug from webdata_find, e.g. apify. |
endpoint required | string | The endpoint path from webdata_find, e.g. /apidojo/tweet-scraper. |
input | object | The endpoint's parameters, as { body?, queryParams?, pathParams? } — each section filled per the matching section of its webdata_inspect schema. Omit for an endpoint that takes none. Must be IDENTICAL between webdata_quote and webdata_run. |
expectedResults | integer | For a per-result endpoint: how many results you expect back — the cap you put in input, or your honest estimate. Turns a per-result price into an estimatedUsd. |
Fetch the outcome of a webdata_run that came back with status other than COMPLETED, FAILED, BLOCKED, STOPPED or TIMED_OUT — a run the provider was still working on when the 60-second wait ended. Pass the runId it returned. One read, no waiting: if it is still going, wait a few seconds and call again. Reading is free; the run itself is billed once, when it completes, at the quoted rate — billedUsd says how much.
| Name | Type | What it is for |
|---|---|---|
runId required | string | The runId a still-running webdata_run returned. |
Run one endpoint and get its data back. REQUIRES the quoteId from webdata_quote for this exact provider, endpoint and input, and you must have shown the operator that price and received their yes — a run with no quote, a stale quote or a quote for different input is refused. THIS SPENDS the organization's monthly allowance, at the quoted rate, on what the provider actually returns. status: COMPLETED means the endpoint was called and answered; check providerStatus for whether it answered well (a 404 there is "not found", not a failure of this tool). billedUsd says what the run cost the organization. An output over 64 KB is cut and flagged truncated: true: a list keeps its shape and loses its tail (omittedItems says how many went), anything else is left out entirely and only outputPreview plus outputBytes come back. Narrow the input rather than re-running — a re-run is a second charge.
| Name | Type | What it is for |
|---|---|---|
provider required | string | The provider slug from webdata_find, e.g. apify. |
endpoint required | string | The endpoint path from webdata_find, e.g. /apidojo/tweet-scraper. |
input | object | The endpoint's parameters, as { body?, queryParams?, pathParams? } — each section filled per the matching section of its webdata_inspect schema. Omit for an endpoint that takes none. Must be IDENTICAL between webdata_quote and webdata_run. |
quoteId required | string | The quoteId webdata_quote returned for this exact provider, endpoint and input. |
THIS STARTS SENDING MESSAGES TO REAL PEOPLE ON LINKEDIN, and a message already sent cannot be un-sent. Activates a draft or paused campaign; startNow (default true) enrolls today's first batch instead of waiting for the next window. Refused with CAMPAIGN_ACTIVATION_BLOCKED (409) unless every precondition holds — params.unmet names each one. **Call campaign_stats first and show the operator what activating will do**: under an ICP gate it returns who the ICP accepted, rejected and never judged, plus when the last person gets in — and no dates while anyone is unjudged, because the gate freezes whoever it rejects. Above zero unjudgedCount, say so and offer list_qualify on the bound lists (campaign_get → leadListIds); a current verdict is a free cache hit. unreadableCount is NOT work to offer: those profiles cannot be read, and they never withhold the dates. Activating an active campaign is a no-op, safe to retry. See the campaign-building skill.
| Name | Type | What it is for |
|---|---|---|
startNow | boolean = true | Default true: enroll today's first batch immediately instead of waiting for the next send window to open. |
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
Ask LinkedIn now which invited leads of a campaign accepted, and move each one on to its next step. Use it when the operator says someone accepted on LinkedIn but Kairon does not show it: Kairon hears of an accept from Unipile, which can be up to ~8 hours late. The same as the refresh on the campaign's Invite Accepted card. It reads each sender seat's connections, so an accept found for ANOTHER campaign on that seat moves on too. kind: checked — found leads moved on (0 means LinkedIn shows no new accept yet), seatsFailed seats could not be read (reconnect them); nobody_waiting — no lead of this campaign is waiting on an accept; cooldown — each seat is checked at most once per 10 minutes, so tell the operator nextCheckAt and do not retry before it.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
Per-lead slot coverage. A {{@slot}} in a send body (either draft mode — an ai_template seed fills it before the writer runs) is filled by each lead's LIST COLUMN of the same name (case-insensitive) — write values with list_set, never a separate copy call. Returns coverage: { slots, ready, missing } over the whole audience plus a page of leads; missingOnly keeps just the ones still short — "what is left to write". campaign_runs cannot answer this: an unfilled lead never enrolls, so it has no run, which is why such a campaign shows a large audienceSize, queuedCount: 0, and sends nothing. 400 if the published graph declares no slots.
| Name | Type | What it is for |
|---|---|---|
id required | string | |
missingOnly | boolean = false | True returns only the leads still MISSING copy — the "what is left to write" read. A lead is not enrolled until every slot is filled, so these are the people the campaign is silently holding back. |
cursor | string | |
limit | integer = 50 |
Create a draft campaign: a name and the sending seats (channelAccountIds, an array of one or more connected LinkedIn accounts — seat_list lists them), and the ICP to gate enrollment on (icpId). There is no default and nothing is selected for you: omitting icpId creates an UNGATED campaign that contacts everyone on its lists. ASK the operator first, every time — show them their ICPs (icp_list; only a published or published_with_draft one can gate — a draft one is refused until icp_publish) and say in one line what each answer means: gated, and only people who qualify are contacted while everyone else on their lists is skipped and never messaged; ungated, and everyone is contacted. "No gate" is a fine answer, but it has to be one they gave. Returns a campaign in draft with no audience and no sequence — bind lists with campaign_update, then campaign_set_graph, then campaign_publish. The campaign-building skill has the full order and the defaults.
| Name | Type | What it is for |
|---|---|---|
name required | string | The campaign's name, for the operator to recognise it in a list. Not seen by any lead. |
channelAccountIds required | string[] | The sending seats — connected LinkedIn accounts in this org, one or more. Each new lead goes to one of them and hears from that seat for the whole sequence; every seat enrolls up to the campaign's dailyEnrollmentLimit a day, so the campaign's daily volume grows with each seat. Duplicates collapse. Get the ids from seat_list — the seats that can send right now. (identity_get 's seatId names your own account even when it is not sendable, so a campaign built on it is created, publishes fine, and is then refused by campaign_activate with sender_not_connected.) |
icpId | string | The ICP to gate enrollment on. There is no default and nothing is selected for you: omitting this creates an UNGATED campaign that contacts everyone on its lists. ASK the operator before calling, every time — never decide it for them and never leave it unasked. Show them their ICPs (icp_list) and say in one line what each answer means: gated, and only people who qualify for that ICP are contacted while everyone else on their lists is skipped and never messaged; ungated, and everyone on their lists is contacted. "No gate" is a fine answer, but it has to be one they gave. |
Read ONE campaign completely enough to REBUILD it. Returns its configuration, the bound lead lists, and BOTH graphs: published (what the engine is sending now) and draft (what campaign_set_graph would overwrite), each in exactly the shape that tool accepts. To copy a campaign, replay what this returns — never retype a sequence someone described, because node configs carry slots, draft modes and limits invisible in prose. The campaign-building skill has the copy recipe.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
Enroll one person from the campaign's audience now, instead of waiting for tomorrow's batch. It skips the DAILY PACING, never the gate: the same ICP and enrollment-filter checks the morning tick runs still judge the lead. So the outcome is data, not a promise — enrolled, skipped (with the reason, e.g. they do not match the ICP), excluded, or failed. A lead who does not fit is marked Skipped rather than messaged.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
leadId required | string | The lead to start, as campaign_runs or list_members returned its id. |
List this org's campaigns, keyset-paginated — { q?, status?, sort?, direction?, cursor?, limit? }. The default order is the one the app shows: the campaigns that are RUNNING first — active, then draft, then paused, then finished, then archived — and the most recent first inside each. q matches the campaign name; status filters to one of draft | active | paused | archived; sort reorders by name | status | sender | updated; page with the returned cursor (null on the last page). Each item is the campaign’s configuration — the same shape campaign_create returns — WITHOUT its sequence graph; read one campaign in full with campaign_get. This is how you find a campaign the operator named but whose id you were not given.
| Name | Type | What it is for |
|---|---|---|
q | string | Case-insensitive substring of the campaign's name. |
status | string | string[] | Return only campaigns in these statuses — one, or several as a comma-separated list (e.g. draft,active,paused). Omit for every status, including the finished and archived ones the app hides by default. |
initiative | string | "none" | Return only campaigns serving this initiative. The literal none returns the campaigns serving no initiative yet — the candidates an initiative can attach (PRD-79 IN8), since a campaign serves at most one. |
cursor | string | Opaque keyset token from the previous page's nextCursor. Omit for the first page. |
limit | integer = 25 | Campaigns per page, 1-100. |
sort | "name" | "status" | "sender" | "updated" | Order the page by this column instead of the default one. The default puts the campaigns that are running first — active, then draft, then paused, then finished, then archived — and the most recent first inside each of those. |
direction | "asc" | "desc" | Which way sort runs. Omit it to get the column read the way the app reads it: names and senders A→Z, statuses running-first, dates newest-first. |
Pause an active campaign. Nothing new fires; runs already in flight wait at their current step and resume there if the campaign is reactivated. Nothing already sent is recalled. Idempotent — pausing a paused campaign returns it unchanged. Refused (400) if the campaign is draft or archived: only an active one can be paused.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
READ THE MESSAGE BEFORE YOU ACTIVATE. Drafts one saved step for up to 3 real audience leads through the SAME pipeline a real send uses, and sends nothing. Name the step by its nodeKey from campaign_get; omit leadIds to sample the audience. Per lead you get the body that would fire, its reasoning and evidence, and an error where no message could be produced — for a step with a Post Condition, no_eligible_post, no_matching_post or condition_check_failed; each lead with at least one eligible post spends one check, judged as on a first lap (no watermark). The reply names which graph it read. **SLOW: expect up to ~2 minutes**, because the LinkedIn profile reads behind the drafts are paced one at a time per seat — do not retry a call that has not returned.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
nodeKey required | string | Which step to preview — the key of a node in the stored graph, as campaign_get returned it. Must be a step that writes something: send_invite, send_message or comment_on_post. Any other type is refused, because it has no message to show you. |
leadIds | string[] | Which real leads to draft for, at most 3. Each must already sit in this campaign's bound audience; one that does not comes back as lead_not_in_audience rather than failing the call. OMIT IT and the first few of the audience are sampled for you, which is what you want unless you are checking one named person. |
graph | "published" | "draft" | Which stored graph to read the step from. Defaults to published — what the engine is actually sending — and falls back to draft when nothing has been published yet. Ask for draft to check an edit you have not published. A {{@slot}} in a draft-graph step previews UNRESOLVED when nobody has written that line yet, which is every slot before the first publish, since per-lead copy is written against the published graph; a slot whose name already carries copy from an earlier publish renders normally here. |
Validate and publish the campaign's draft graph. Publish can FAIL validation, and it fails as DATA, not as an exception: published: false with EVERY error in errors (each naming its code and node), so one pass fixes all of them. On success version carries the freshly published graph. migrateInFlight additionally repoints in-flight runs onto it by node key, canceling any run parked on a node the new version removed. The campaign-building skill lists the error codes.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
migrateInFlight | boolean | Default false: publishing pins the new version for FUTURE enrollments only, and runs already in flight finish on the version they started. True repoints them onto the new version by node key — a run parked on a step the new version removed is CANCELED, not migrated. |
The operator's Tasks page, as one read: every send booked to fire (autopilot), every manual run waiting for approval (review), every AI draft that errored (failed), and everything already acted on today (done), plus each seat's daily budget. Filter with kind and campaignId. This is where the runId and nodeRunId that every per-run tool takes come from — a review row's parked send has no other index. Each row's body holds the words that will go out — an invite's note, a message, a comment — told apart by actionType, so "what are we about to send" is one call. It is a live WINDOW: limit applies per kind so one busy kind cannot hide another, and windowCapped: true means total is a floor. To read one campaign exhaustively use campaign_runs, which pages with a cursor; for one row's reasoning, citations and timeline, open it with campaign_run_get.
| Name | Type | What it is for |
|---|---|---|
kind | "autopilot" | "review" | "failed" | "done" | Which slice: autopilot (booked, about to fire), review (waiting for approval on a manual campaign), failed (the AI draft errored), done (already acted today). Omit for all four. |
campaignId | string | Only rows belonging to this campaign — applied to the desk WINDOW, not to the campaign. The desk holds a bounded slice of the org, so a campaign whose rows fall outside it comes back empty; windowCapped: true is how you tell that apart from "nothing is queued". For an exhaustive per-campaign read use campaign_runs, which pages with a cursor. |
limit | integer | Rows PER KIND, 1-200, default 25 — so an unfiltered call can return up to FOUR times this. Spent per kind on purpose: one busy kind must not push the others off the page, and a review row is reachable from no other index. Pass kind to spend the whole budget on one. Raise it only when you actually need the long tail: every row carries the draft it will send. |
THIS SENDS THE MESSAGE. On a manual campaign every send waits for a human; this releases one. Read the draft first with campaign_run_get — approving without reading is how an agent sends words nobody checked. If the send was held by the pre-send check, approving is an OVERRIDE and is recorded against your name on the message itself. Refused (409) if the run is not awaiting approval, or if its campaign is paused — resume it first. Edit the words first with campaign_send_edit.
| Name | Type | What it is for |
|---|---|---|
runId required | string | One run's id — starts with srun_, as campaign_runs or campaign_review_feed returned it. A run is one enrolled lead. NOT a feed row's id or nodeRunId. |
Stop up to 100 enrolled leads: each run ends, anything drafted or booked for them is dropped, and their parked workflow is torn down so no timer fires later. Nothing already SENT is recalled. This is final for that campaign — a canceled person moves to skipped and it will not pick them up again (re-adding them via another bound list is the only way back). Ids are DEDUPED, so read canceled rather than counting what you sent. Every distinct id comes back with its own outcome: canceled, not_found, not_cancelable once a run has already finished, or failed — that one alone is unknown, so re-send it. A wrong-KIND id never gets that far: ids are checked for the srun_ prefix at the door, so a feed row’s nodeRunId is refused outright rather than counted as a retryable failure. Idempotent on an already-canceled run.
| Name | Type | What it is for |
|---|---|---|
runIds required | string[] | The runs to end — each starts with srun_, as campaign_runs or campaign_review_feed returned them. NOT a feed row's id or nodeRunId: on an autopilot row those name the SEND, and a batch that accepted them would report a retryable failure for an id that can never succeed. |
Read ONE sequence run in full, by an id from campaign_runs. Returns the lead, the sending seat, the run's position, and the node-by-node timeline with each step's status and timing. When a send is drafted or already out, body carries the actual text, with its reasoning and evidence for an AI draft. This is how you answer "what did we actually say to this person, and what happened next" — campaign_runs gives you the page, this gives you the story.
| Name | Type | What it is for |
|---|---|---|
runId required | string | One run's id, as campaign_runs returned it — a run is one enrolled lead. |
Pass over the step a run is on WITHOUT ending the run: the drafted or booked message, comment, email, like, visit, follow, research or removal never happens, and the person carries straight on to the next step (or finishes, when it was the last — next: 'completed'). This is the answer to "not this one" that campaign_run_cancel is not: cancel ends their whole sequence for good. Refused with CAMPAIGN_RUN_NOT_ACTIONABLE on an invite (the accept wait after it would wait on nothing), on a wait or a delay (a delay is campaign_run_skip_wait), on an A/B split or an agent conversation, on a step already firing, on a message still being WRITTEN (skip it once it is booked or held for review), on an email not held for review, and on a paused campaign. NOT idempotent: it skips the step the run is on NOW, so calling it twice skips TWO steps. Read campaign_run_get first if you are unsure where the run stands, and never retry a success.
| Name | Type | What it is for |
|---|---|---|
runId required | string | One run's id — starts with srun_, as campaign_runs or campaign_review_feed returned it. A run is one enrolled lead. NOT a feed row's id or nodeRunId. |
Cut short the delay step a run is parked on so the sequence carries straight on to its next step. Refused on a run that is NOT on a delay: a booked send is campaign_send_now, and a wait_connection_accepted cannot be skipped at all — the skip would not match the wait it is parked on, and no amount of skipping makes someone accept an invite. DO NOT RETRY a refusal: once the wait is skipped the run has moved past it, so a second call reports CAMPAIGN_RUN_NOT_ACTIONABLE for work that already succeeded.
| Name | Type | What it is for |
|---|---|---|
runId required | string | One run's id — starts with srun_, as campaign_runs or campaign_review_feed returned it. A run is one enrolled lead. NOT a feed row's id or nodeRunId. |
Read the campaign's sequence runs — one per enrolled lead — keyset-paginated; page with the returned cursor. Each run carries the lead, where it stands, when it acts next, its outcome once terminal, and any error or skip detail. bucket filters to one activity tab (waiting, didnt_accept, didnt_answer, replied, interested, skipped, error); omit for every run. stage takes the same words as a SET; q, dateFrom / dateTo and listId narrow further, and filters stack. nodeKey + dateFrom / dateTo asks **who completed THAT step on those days** — the only way to tell a first message from a follow-up when a sequence has several, since campaign_stats counts them as one number. Without nodeKey the day window holds runs whose LAST event or next BOOKED action fell in it. campaign_stats says which bucket is worth reading. includeQualification: true attaches each lead's STORED scorecard — qualification_get 's body — which is big, so pair it with a small limit.
| Name | Type | What it is for |
|---|---|---|
bucket | "waiting" | "didnt_accept" | "didnt_answer" | "replied" | "skipped" | "error" | "interested" | Filter to one activity bucket: waiting, didnt_accept, didnt_answer, replied, interested (a subset of replied), skipped (an ICP / enrollment-filter verdict or an operator stop), or error. Omit for every run. |
stage | string | string[] | Activity states to include; several read as "any of these". |
nodeKey | string | Only runs that reached this step, by the node key the graph gave it — including runs that have since moved past it or finished. Pair it with dateFrom / dateTo to ask who completed THIS step on those days, which is the only way to tell a first message apart from a follow-up in a sequence with more than one message step. |
q | string | Find a person by name, headline, company or LinkedIn handle. |
dateFrom | string | Earliest day to include, inclusive (YYYY-MM-DD). Dates each run by its LAST event — unless nodeKey is also set, which dates it by that step instead. |
dateTo | string | Latest day to include, inclusive (YYYY-MM-DD). Dates each run by its LAST event — unless nodeKey is also set, which dates it by that step instead. |
listId | string | string[] | Only people enrolled from these source lists. |
qualificationStatus | string | string[] | Only people with these ICP verdicts; several read as "any of these". |
funnel | "invite_sent" | "invite_accepted" | "message_sent" | "replied" | "interested" | Only the people one Performance funnel card counts: invite_sent, invite_accepted, message_sent, replied or interested. |
cursor | string | |
limit | integer = 50 | |
id required | string | |
includeQualification | boolean | Attach each run's stored qualification scorecard (the same body qualification_get returns for its lead). Off by default because it is BIG — every criterion with its rationale and evidence — so pair it with a small limit (10-25) rather than a full page. |
Add a LinkedIn seat as a sender of a campaign (draft, active or paused). Each new lead goes to one of the campaign's senders and hears from that seat for its whole sequence and every reply; every sender enrolls up to its own daily limit, so the campaign's daily volume grows with each one. The added seat takes new leads from the next enrollment — leads already running keep their seat. Re-adding a removed sender makes it live again. Refused with CAMPAIGN_SEAT_LACKS_CAPABILITY naming the step when the seat cannot run a step of the graph.
| Name | Type | What it is for |
|---|---|---|
channelAccountId required | string | The seat to add as a sender — a connected LinkedIn account in this org (seat_list). It takes new leads from the next enrollment; leads already running keep their own seat. Re-adding a removed sender makes it live again. Refused, naming the step, when the seat cannot run a step of the graph (an InMail step or an invite note needs Premium or Sales Navigator). |
dailyEnrollmentLimit | integer | This sender's own new leads per day, 1-200. Omit to use the campaign's default (dailyEnrollmentLimit). |
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
Remove a sender from a campaign. It takes no new leads from now on. leads is REQUIRED and is the operator's choice — ask them: finish lets the leads it is mid-sequence with keep getting their follow-ups from it until their sequence ends; stop cancels those runs now and nothing more is sent to them from this campaign. Either way its leads with nothing sent go back to the pool for the other senders (returnedToPool). The last live sender cannot be removed (CAMPAIGN_NEEDS_A_SENDER) — add another first.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
channelAccountId required | string | The sender seat — a channel account id from the campaign view’s seats. |
leads required | "finish" | "stop" | What happens to the leads this sender is mid-sequence with. finish: they keep getting their follow-ups from this sender until their sequence ends. stop: their runs are canceled now and nothing more is sent to them from this campaign. Either way, its leads with nothing sent yet go back to the pool for the other senders. There is no default — ask the operator. |
Set how many new leads one sender enrolls per day for this campaign, 1-200, or null to return it to the campaign's default (dailyEnrollmentLimit on the campaign). The seat's own LinkedIn action limits still apply on top.
| Name | Type | What it is for |
|---|---|---|
dailyEnrollmentLimit required | integer | null | This sender's own new leads per day, 1-200, or null to go back to the campaign's default. |
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
channelAccountId required | string | The sender seat — a channel account id from the campaign view’s seats. |
Replace the words a drafted or scheduled send will fire with — the edited text is exactly what goes out, never re-written afterwards. Addressed by nodeRunId (from campaign_review_feed), NOT the run id. Refused (409) once the send is out; there is no editing a sent message. On a send held by the pre-send check, editing CLEARS the warning: it described a sentence that no longer exists, and your own words are yours. An InMail may carry a new subject; every other send type refuses one.
| Name | Type | What it is for |
|---|---|---|
nodeRunId required | string | The booked send — starts with nrun_, as campaign_review_feed returned its nodeRunId. NOT the run id. Null on a feed row means nothing is drafted yet, so there is nothing to act on. |
body required | string | The exact text that will be sent — this REPLACES the draft, it is not an instruction to rewrite it. Empty is allowed only on a connection request, which then fires with no note. On a MESSAGE: To send this as SEVERAL messages rather than one, separate them with a line containing only ---. Each part arrives as its own LinkedIn message a couple of seconds later, in the same conversation — what a person does when they type a short opener and then the reason they wrote. The whole thing still counts as ONE send against the daily limit. This is the same thing the "Add bubble" button does in the campaign editor. On every other send type a --- line is sent as written. |
subject | string | An InMail subject, and only that: refused on every other send type. It is the whole of what the recipient sees before deciding to open, and a credit is spent either way. |
THIS SENDS THE MESSAGE, sooner than anything else would have: it fires this run's next booked send NOW, outside the send window and the usual pacing. It is a HAND-OFF — the run is armed and the sender fires it a moment later, so campaign_review_feed shows expediteNextSend: true until it goes. DO NOT RETRY: a second call is refused with CAMPAIGN_RUN_NOT_ACTIONABLE because the run is already armed, which means the send is on its way, not that it failed. Only a run parked on a send with a reviewed draft can be expedited; one sitting on a delay is refused (use campaign_run_skip_wait).
| Name | Type | What it is for |
|---|---|---|
runId required | string | One run's id — starts with srun_, as campaign_runs or campaign_review_feed returned it. A run is one enrolled lead. NOT a feed row's id or nodeRunId. |
Replace the campaign's DRAFT sequence graph IN FULL — it overwrites, it is not a patch. Nodes are { key, type, config } with a stable key you choose; edges are { fromKey, toKey, condition }. Layout is automatic. An edge's condition is an outcome its source type emits: send_invite→sent, wait_connection_accepted→timeout/accepted*, delay→default*, send_message→sent, like_last_post→done, visit_profile→done, follow_profile→done, remove_relation→done, comment_on_post→commented, send_inmail→sent, send_email→sent, audience_split→branch_a*/branch_b*, wait_reply→timeout/replied, agentic_reply→no_reply/done (* must be wired). Which types may follow which is on the edges field; a violation is refused (400). The response is the saved graph, in the shape this tool accepts — read it back. **The campaign-building skill has the build order; read the Framework axioms you name on appliedAxioms BEFORE proposing a flow.** Saving is not publishing.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
appliedAxioms required | string[] | Which Framework axioms you wrote this sequence against — slugs from framework_list, at least one, and read them with framework_get before you propose the flow rather than after. The ones that govern copy are why-the-offer-matters-more-than-the-wording, why-one-message-carries-one-person-and-one-pain, how-short-a-first-message-should-be, how-to-structure-a-first-message, how-to-personalise-with-signals-rather-than-trivia and how-to-write-follow-ups. This records what you read. It does NOT hold the graph to it: the axioms are what Kairon sees working, the operator decides, and a sequence that departs from them on purpose saves like any other. An unknown slug is refused and the refusal names the real ones. |
graph required | object | The whole draft sequence. This REPLACES the existing draft graph — it is not a patch, so send every step you want to keep. Saving is not publishing: the graph stays invisible to every run until campaign_publish. |
Edit a campaign's configuration — any subset of its fields; omitted ones are unchanged. This is also how an audience is bound, via leadListIds, and that binding is **additive**: a bound list you leave out is NOT unbound, because unbinding would cancel every in-flight run drawn from it. It is also how a campaign is pointed at the bet it serves, via initiativeId (null detaches) — do that for every campaign you build under an initiative, or the bet reports an empty funnel forever. sendWindow is required before campaign_activate will run. autonomy is 'autopilot' (sends fire on schedule) or 'manual' (each send waits for approval, except a note-less invite).
| Name | Type | What it is for |
|---|---|---|
name | string | The campaign's name, for the operator to recognise it. Not seen by any lead. |
sendWindow | object | When sends may fire. The ONE piece of configuration with no default — campaign_activate is blocked with no_send_window until it exists. Monday-Friday 12:00-18:00 in the operator's own zone is a sane start. |
autonomy | "manual" | "autopilot" | autopilot (the default) fires each send on schedule. manual parks EVERY send for the operator to approve first, so a manual campaign looks active and sends nothing until someone works the queue. |
allowsReEntry | boolean | May a person enter this campaign more than once? false (the default) means someone who finished it never comes back — the rule every campaign has always had. true lets them re-enter later, which is what a nurture campaign is for. It never permits two messages at once: one live run per person, always, whatever this says. |
dailyEnrollmentLimit | integer | The DEFAULT new leads per day, per sender, 1-200 (default 25): every sender without a limit of its own enrolls up to this many a day, so the campaign's day is the sum over its senders. Caps the START of new runs, not sends — the org's own per-seat limits still apply on top. Senders are added, removed and given their own limit with campaign_seat_add / campaign_seat_remove / campaign_seat_update. |
priority | integer | null | Which campaign this LinkedIn account serves first when two of them are due on the same day. 0 is highest. Lower sorts first, matching every other priority in Kairon. null clears it — no priority, served after Low; omitted leaves it unchanged. It is an ORDER, never an allowance: it does not change how many leads any campaign enrols (dailyEnrollmentLimit does that), and it never moves a send that is already scheduled. |
endsOn | string | null | The day this campaign stops, YYYY-MM-DD, in its send window's time zone. On that day it pauses itself (reason ended) and nothing more goes out — no new leads, no follow-ups. Today counts: setting today or an earlier day on a running campaign pauses it AT ONCE, so to make today the last sending day, pass tomorrow. Use it for a campaign tied to a dated event. null clears it; omitted leaves it unchanged. To run it again after it ended, move the date later or clear it, then resume. |
enrollmentFilter | object | Which prior-contact history may enroll, and whose. Default is everyone. A narrowed filter SKIPS the people it excludes rather than queueing them, so it is a common reason a big audience enrolls almost nobody. |
icpId | string | null | The ICP that gates enrollment. null clears it (enroll everyone); omitted leaves it unchanged. Must be an ICP in this org. Leads that fail it are marked Skipped, never messaged. |
stopOnReply | boolean | Default true — a reply halts that person's run so nothing follows a real conversation. false keeps the sequence going after they answer; set it only when the operator asked. |
connectionDegree | "any" | "only_first" | "exclude_first" | Whether the sender's existing LinkedIn connection matters. any (the default) ignores it. only_first enrolls ONLY people the sender is already connected to. exclude_first enrolls only people they are NOT connected to — the usual choice for cold outreach that opens with an invite. Anything but any costs one extra LinkedIn read per candidate, and skips the people it excludes rather than queueing them. |
stopOnCompanyReply | "off" | "any_reply" | "interested" | Whether one person answering stops their colleagues. off (the default) works every lead on their own merits. any_reply stops the rest of a company once anyone there answers THIS campaign. interested stops it only when the answer was an interested one, so a colleague's "no thanks" leaves the others working. Colleagues are matched on their LinkedIn employer, so someone whose employer Kairon has never seen is never stopped by it. It stops people waiting to enter AND people already inside — but never someone who has written to you, whose conversation always continues. |
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
leadListIds | string[] | Lead lists to bind, ADDITIVELY. A list already bound stays bound, and a bound list you leave out is NOT unbound — omission cannot unbind, because that would cancel every in-flight run drawn from the list. |
initiativeId | string | null | The initiative this campaign serves. null detaches it; omitted leaves it unchanged. A campaign serves at most ONE bet, so a different id MOVES it. Detaching changes nothing else — a campaign that outlives its bet keeps sending. |
Copy an earlier published version back into the campaign's editable DRAFT, steps and routing and layout intact — the way to undo an edit that replaced copy someone wrote. It does NOT publish: leads keep getting the live version until someone publishes the draft, and in-flight runs are untouched either way. It REPLACES any unpublished draft, so read campaign_versions first if one exists. Publishing afterwards mints the next version number; the old one stays where it is.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
versionNumber required | integer | The version to bring back, as campaign_versions listed it. |
Every version this campaign has published, newest first, plus its unpublished draft — with a line per version saying what it changed (steps added/removed/edited, routing). Pass versionNumber to get that version's whole graph back, message bodies and all, in exactly the shape campaign_set_graph accepts — so you can hand it straight back instead of retyping copy. History is publish-boundary: edits between two publishes overwrote one draft and were never stored.
| Name | Type | What it is for |
|---|---|---|
id required | string | The campaign id, as campaign_list or campaign_create returned it. |
versionNumber | integer | Omit for the list of versions. Give one to get THAT version’s full graph, in exactly the shape campaign_set_graph accepts. |
Remove one checkpoint from a bet’s history. Deleting the newest one moves the initiative’s health back to whatever the one before it said — or to unknown when there is none. Use it for an entry published against the wrong bet or by accident, never to tidy away a verdict somebody did not like.
| Name | Type | What it is for |
|---|---|---|
initiativeId required | string | The initiative the checkpoint belongs to. |
checkpointId required | string | The checkpoint id (ckpt_…), as initiative_get returned it in the history. |
Fix what one checkpoint says, or the verdict it froze — for a typo or a verdict typed by mistake. The entry keeps the date it was made and its author, and every surface marks it as edited. A checkpoint that has gone out of date is NOT fixed here: publish a newer one instead, so the history shows the change of mind. A rewritten body carries @mentions the same way checkpoint_publish does.
| Name | Type | What it is for |
|---|---|---|
body | string | What happened and the ONE thing to do next, in markdown. Say the numbers you read and how they compare to this org’s own baseline, and whether the last checkpoint’s recommendation was actually done. To put a teammate’s name on a next step, mention them: [@Ana](#kairon-ana@co.com) — a normal markdown link whose destination is #kairon- plus their user id, email, or name (spaces as +). Whichever you use is stored as their id, and a name nobody here matches, or one two teammates share, is refused rather than saved as prose. Read user ids and names off seat_list. |
health | "on_track" | "at_risk" | "off_track" | The verdict, frozen at publish: on_track, at_risk or off_track. Judge VOLUME before rates — a low number of sends is not a bad result, it is not a result yet. |
initiativeId required | string | The initiative the checkpoint belongs to. |
checkpointId required | string | The checkpoint id (ckpt_…), as initiative_get returned it in the history. |
Record a dated verdict on an initiative: what happened, the one thing to do next, and a health of on_track, at_risk or off_track. The verdict is FROZEN — publishing a newer one never rewrites an older one, which is what makes the history evidence. Read the gtm-checkpoint skill first; it carries the rule about judging volume before rates.
| Name | Type | What it is for |
|---|---|---|
body required | string | What happened and the ONE thing to do next, in markdown. Say the numbers you read and how they compare to this org’s own baseline, and whether the last checkpoint’s recommendation was actually done. To put a teammate’s name on a next step, mention them: [@Ana](#kairon-ana@co.com) — a normal markdown link whose destination is #kairon- plus their user id, email, or name (spaces as +). Whichever you use is stored as their id, and a name nobody here matches, or one two teammates share, is refused rather than saved as prose. Read user ids and names off seat_list. |
health required | "on_track" | "at_risk" | "off_track" | The verdict, frozen at publish: on_track, at_risk or off_track. Judge VOLUME before rates — a low number of sends is not a bad result, it is not a result yet. |
initiativeId required | string | The initiative being judged (init_…). |
Remove one initiative from the board. A soft delete: the campaigns serving it detach and keep running, and its checkpoints survive. Use it for a bet created by mistake — most often a duplicate left behind by an initiative_set that omitted initiativeId. Never use it to retire a bet that ran: set its status to completed or canceled with initiative_set, so the board keeps what was learned.
| Name | Type | What it is for |
|---|---|---|
initiativeId required | string | The initiative to remove, by id (init_…). |
One initiative whole: the thinking in its body, the campaigns serving it as the campaigns list knows them, and every checkpoint newest-first with the verdict each one froze. Read this before publishing a checkpoint — the last one says what was recommended, and whether it was done is the question a checkpoint has to answer.
| Name | Type | What it is for |
|---|---|---|
initiativeId required | string | The initiative to read, by id (init_…). |
Every initiative in the organization: what is being bet on, its status, the health of its latest checkpoint and how many days old that verdict is, plus the summed funnel of the campaigns serving it. Read this before proposing work — a campaign that serves no bet is how fifteen open fronts happen.
| Name | Type | What it is for |
|---|---|---|
status | "draft" | "active" | "completed" | "canceled" | Return only initiatives in this state. Omit for all of them, including completed and canceled ones. |
Write down a bet: a name, the thinking in markdown, and who is accountable for it. Omit initiativeId to create, pass one to edit. Call initiative_list FIRST and reuse the id of any bet that already covers this — a second call that omits the id creates a duplicate rather than updating what you just wrote. Health is NOT a field here and never will be — an initiative reports the verdict of its newest checkpoint, so the way to move it is checkpoint_publish. Read the gtm-initiative skill before creating one.
| Name | Type | What it is for |
|---|---|---|
name | string | Short, scannable name for the bet — "Expansión Centroamérica". REQUIRED when creating (no initiativeId); optional when editing. A create without one is refused rather than defaulted. |
body | string | null | The thinking, in markdown: what is being bet on, why, what would prove it worked, and what has been learned. Free-form on purpose — write what an operator would want to reread in three weeks. Put a teammate on a step by mentioning them: [@Ana](#kairon-ana@co.com), a normal markdown link whose destination is #kairon- plus their user id, email, or name (spaces as +). It is stored as their id; a name nobody here matches, or one two teammates share, is refused. seat_list names them. |
status | "draft" | "active" | "completed" | "canceled" | draft while it is being shaped, active once it is being worked. |
priority | integer | null | 0 is highest. Lower sorts first, matching every other priority in Kairon. |
startDate | string | null | YYYY-MM-DD, this century. A bet starts on a date, not at a moment. |
endDate | string | null | YYYY-MM-DD, this century. When the bet should have paid off, or be called. Must not be before startDate. |
initiativeId | string | Omit to create a new initiative. Pass an id (init_…) to edit that one. Call initiative_list FIRST and reuse the id of any bet that already covers this — omitting it a second time creates a duplicate, it does not update. |
owner | object | null | Who is accountable for this bet — one org member, by email (what you usually have) or userId. null leaves it unassigned; omit to leave it as it is. |
The org-wide outreach funnel over a date window — invites sent → accepted → messages → replied → interested — with the daily activity series and a per-seat breakdown, each seat carrying its campaigns and their acceptance, reply and interested RATES. This is the account-level question campaign_stats cannot answer: that one needs a campaign named first, and this spans every campaign and every seat. Narrow to one seat with userId (from the breakdown). Days are bucketed in your own stored timezone, not UTC.
| Name | Type | What it is for |
|---|---|---|
from required | string | First day of the window, YYYY-MM-DD, inclusive. |
to required | string | Last day of the window, YYYY-MM-DD, inclusive. Must be on or after from, and the window must not exceed 366 days. |
userId | string | Narrow to one seat owner. Omit for every seat in the organization. |
List conversations, newest activity first, across every channel that holds them. Each chat says which one it is in channel ("linkedin" or "email"). Defaults to YOUR OWN seat — pass scope: "org" for every seat in the organization, or seatId for one teammate. **Email conversations belong to no seat, so they appear only under scope: "org".** Filter by unread, answered, classification (what their last inbound message meant — interested / objection / neutral / not_interested, NOT a Pipeline stage), leads-only, or q, which matches the counterpart's NAME and does not search message text. Combine answered: "no" with classification: "interested" for replies that said yes and you have not answered. Each chat carries the id that chat_messages takes, the counterpart, any linked lead, and a last-message preview; canReply says whether you can answer it from here. Page with the returned cursor. Reading is free: never metered, never against a sending limit.
| Name | Type | What it is for |
|---|---|---|
seatId | string | One teammate's seat id, from seat_list. Defaults to your own. |
scope | "mine" | "org" | 'mine' (default) is your own seat; 'org' is every seat in the organization. |
unread | boolean | True returns only chats with unread messages. |
answered | "yes" | "no" | 'no' returns chats waiting on a reply from you (they spoke last); 'yes' returns the ones you already answered. Chats whose history has not been loaded yet match neither. |
leadsOnly | boolean | Only chats already linked to a lead. |
needsHuman | boolean | Only chats where Kairon handed the conversation back and is waiting on you. |
classification | "interested" | "objection" | "neutral" | "not_interested" | What their last inbound message meant — not a Pipeline stage. Combine with answered: "no" for interested replies you have not answered. |
q | string | Case-insensitive match on the counterpart's NAME. |
cursor | string | |
limit | integer |
Read one conversation's messages, **newest first**, by the chatId from chat_list — LinkedIn or email. Each message has direction ("in" = from them, "out" = from you), body, sentAt, and any attachments; a message the counterpart deleted comes back with deleted: true and an empty body. An email message also carries its subject, and isAutoReply when the channel flagged it machine-sent — an out-of-office is a real message but not an answer. Page further back in history with the returned cursor. Opening a thread may pull fresh messages from LinkedIn, which is not metered and does not count against any sending limit.
| Name | Type | What it is for |
|---|---|---|
chatId required | string | |
cursor | string | |
limit | integer |
Send an InMail (optional subject) to the person target addresses. Requires a live InMail credit on the seat and nothing else - any paid LinkedIn plan that grants them will do; refused fast without one. An attachment is one form or the other: inline dataBase64 (at most ONE per send, ≤3 MB, it rides in the JSON body) or a url we fetch server-side (up to 5, ≤15 MB each). Reach for url when you cannot produce base64. This contacts a real person AND spends a credit, with no approval step.
| Name | Type | What it is for |
|---|---|---|
target required | object | Who this acts on — one LinkedIn person or company, named by URL, vanity slug, or provider id. |
subject | string | The InMail subject line — the one thing that decides whether it is opened. |
text required | string | The InMail body, as it will be sent. Over MCP, em dashes and curly quotes become plain punctuation; nothing else rewrites it. |
attachments | object[] |
Send a LinkedIn connection invitation (optional note) through the caller's account. Address the recipient with target — { type, value } (url, public_identifier, or provider_id). Refused if already connected/invited; deduped per recipient; capped daily. This contacts a real person — there is no human approval step.
| Name | Type | What it is for |
|---|---|---|
target required | object | Who this acts on — one LinkedIn person or company, named by URL, vanity slug, or provider id. |
note | string | The connection-request note, up to 300 characters. Omit it entirely for a note-less invite, which is valid and often accepts better. |
Retract a message YOU sent, addressed by the chatId and message id that chat_messages returns. Only outbound messages can be deleted — an inbound one is a not-found. Removes it from LinkedIn and marks it deleted in the Kairon conversation. Idempotent. Metered.
| Name | Type | What it is for |
|---|---|---|
chatId required | string | |
messageId required | string | The message's id from chat_messages. |
Replace the body of a message YOU sent, addressed by the chatId and message id that chat_messages returns. Only outbound messages can be edited — an inbound one is a not-found. LinkedIn allows this on Classic messages within about an hour of sending; later attempts are refused by LinkedIn. Updates the conversation in Kairon as well as on LinkedIn. Metered.
| Name | Type | What it is for |
|---|---|---|
chatId required | string | |
messageId required | string | The message's id from chat_messages. |
text required | string | The replacement body. It overwrites the message for the recipient too. |
Send a LinkedIn direct message. Pass EXACTLY ONE of chatId — reply in an existing conversation (an id from chat_list), which is what you want when it exists; an EMAIL chat is refused, because Kairon cannot send email yet — or target to open a NEW one, allowed only with a 1st-degree connection or an open profile. Attachments: at most ONE inline base64 file (≤3 MB, it rides in the JSON body), up to 5 total when the rest are https urls we fetch server-side. Returns chatId and the persisted message. This contacts a real person, with no approval step.
| Name | Type | What it is for |
|---|---|---|
chatId | string | Reply in this existing conversation (from chat_list). Use this, or target. |
target | object | Open a NEW conversation with this person. Use this, or chatId. |
text required | string | The message body, as it will be sent. Em dashes and curly quotes become plain punctuation; nothing else rewrites it. |
attachments | object[] | |
clientToken | string | Your own idempotency key. Send the SAME token when retrying after a lost response and the message is not sent twice. |
Who this connection acts as: the user, their organization, the seat (seatId plus its state: connected, none, or locked — needs a subscription), linkedin, and capabilities. Call it after impersonate_start / impersonate_stop and before anything that sends, so you know whose account a message leaves from. seat also explains a refusal. ** search_sales_navigator always works — on every seat and with none.** capabilities.viewerRelativeFilters covers ONE part of a Sales Navigator search: whether filters measured from you (networkDistance, connectionsOf, saved lists) can be answered. False leaves the rest of the grammar — industry, headcount, seniority, function, tenure — running as on a paid seat, and is NEVER a reason to search elsewhere. When linkedin.hydrated is false, pass its publicIdentifier to linkedin_fetch. seatId names your own account even when it cannot send now (one awaiting a reconnect or 2FA keeps its id); seat_list is the seats that CAN send.
Takes no parameters.
The users you may act as, each with their organization and whether they have a LinkedIn account connected — you can only act as someone who does. Staff see every tenant; an org owner or admin sees the members of the organizations they govern. search matches name, email or organization.
| Name | Type | What it is for |
|---|---|---|
search | string | Filter by name, email address or organization name. |
Act as another user from now on: every later call runs as them, on their LinkedIn seat and their organization. Staff may act as anyone; an org owner or admin only as a member of their own organization, never one ranking above them. user is an email address or a user id. Calling it again switches target. The mode belongs to this client alone and ends 60 minutes after your last call, or on impersonate_stop.
| Name | Type | What it is for |
|---|---|---|
user required | string | The email address or user id to act as. |
Go back to being yourself. Succeeds whether or not you were acting as anyone, and reports which it was.
Takes no parameters.
The LinkedIn seats this organization can send FROM (the campaign wizard's sender picker), every one connected and ready. Call it BEFORE campaign_create: a seat's id is exactly what goes in that tool's channelAccountIds array, and what chat_list takes as seatId. ownerName is the teammate it belongs to, ownerUserId is that teammate's own id (what an @mention in an initiative or checkpoint body stores), and isSelf marks your own; any member may send from any of them, and the chosen seat's owner is the campaign's author. canSendInmail says whether an InMail step is legal on that seat — it gates that step and nothing else, never a search. canSendInviteNote false means its invites carry no note. authCountry is where it authenticates — open a send window on noon there. An empty list means no seat here is ready to send from. If you expected one, ask the operator to contact support at admin@heykairon.com. Free: it reads nothing on LinkedIn and spends no daily limit.
Takes no parameters.
What is left of the organization's monthly AI balance: remainingUsd (can be negative), includedUsd, usedUsd (null when it cannot be derived), and periodEndsAt when it resets. Call this BEFORE quoting a webdata_run, so you can tell the operator what they hold now and what the run would leave them with. When it answers complimentary, quote no money at all. Reading it spends nothing.
Takes no parameters.
What the operator has told Kairon: their competitor and source URLs, and their organization's value proposition, lead magnet and learnings log. Read this FIRST when deciding what they should work on next. It holds no writing rules: for the rules any draft is held to, theirs and Kairon's own, call writing_check_list. Name sections to read only some.
| Name | Type | What it is for |
|---|---|---|
sections | "competitorUrls" | "sourceUrls" | "valueProposition" | "leadMagnet" | "learnings"[] | Which sections to read. Omit for all five. |
Replace one or more sections of what the operator has told Kairon: competitorUrls, sourceUrls, valueProposition, leadMagnet, learnings. Each section you name is replaced WHOLE; each one you omit is untouched. When the operator gives you a whole section, send it straight here. Only when ADDING to what is already stored (one more item, another learnings entry) call config_get first and send the merged text. An empty array or string clears a section. It writes no writing rules: writing_check_set is the only door to those, in every section.
| Name | Type | What it is for |
|---|---|---|
competitorUrls | string[] | Replaces every competitor URL. Each must be a LinkedIn URL. |
sourceUrls | string[] | Replaces every source URL. Any http(s) URL. |
valueProposition | string | Replaces the organization's value proposition. An empty string clears it. |
leadMagnet | string | Replaces the organization's lead magnet — what it gives a prospect to earn a reply. An empty string clears it. |
learnings | string | Replaces the organization's GTM learnings log WHOLE. To append an entry, read the log first and write back the joined text. An empty string clears it. |
One product doc in full, as markdown, by a slug from doc_list. Ground what you tell the operator in what it says — do not invent features, steps, limits or settings that are not in it, and when the doc states a constraint, report the constraint rather than working around it. Pair with skill_get when the question is not "what does Kairon do" but "how do I do this well".
| Name | Type | What it is for |
|---|---|---|
slug required | string | Which doc to read — a slug from doc_list, e.g. "campaigns". |
Kairon's product reference — how each part behaves and where it stops. doc_get returns one in full, by a slug listed here. Call this BEFORE answering how Kairon works, what it can do, or why it is doing something — troubleshooting included ("nothing is sending", "is this a bug"). You do not know this product: it has documented rules that cause exactly those symptoms on purpose, and they are not guessable from other outreach tools. A guess tells an operator their working product is broken.
Takes no parameters.
Order a sending domain and its mailboxes. **KAIRON pays, not the operator** — never quote them a price as something they will be charged; email_domain_quote 's figures are Kairon's cost, and agreedTotal only makes the order refuse if that cost moved. What DOES need their explicit yes: the order cannot be undone, and the domain is registered and kept by Kairon's sending provider, so if they leave Kairon they cannot take it or its email history with them. Four endings, and only placed ordered anything — rejected means the name was refused, so pick another; payment_failed and checkout_required are both Kairon's to fix. The operator is charged nothing in any of them. Say which happened rather than assuming success. Mailboxes appear a few minutes after a successful order; then call email_warmup_start, because a mailbox that is not warming builds no reputation.
| Name | Type | What it is for |
|---|---|---|
domain required | string | The domain name to buy and send from, like acme-outreach.com. Only .com and .org can be registered, and a name carrying a well-known trademark is refused. Not the customer's real website — a sending domain is a separate name, so a problem with it never touches their main domain's reputation. |
mailboxes required | object[] | The mailboxes to create on the domain — one to five, each a real person on the team, since a recipient who replies is replying to that name. More mailboxes means more sending capacity and more monthly cost. |
forwardingDomain | string | The customer's real website, where a visitor to the sending domain is sent — acme.com. Worth setting: a sending domain that resolves to nothing is exactly what a suspicious recipient checks. Any extension, not just.com/.org. Ask the operator; never guess it from their email address. |
agreedTotal required | number | The dueNow from the quote the operator agreed to, in whole dollars. The order is re-priced against the vendor immediately before buying and refused if the number moved, so a stale quote can never charge a different amount than the one shown. |
Ask Instantly whether this domain's MX, SPF, DKIM and DMARC records are set up correctly. Use it after the customer edits their DNS: records take minutes to hours to spread, so calling this again IS the retry — a failing answer now is not a permanent one. The domain must be one this workspace already sends from.
| Name | Type | What it is for |
|---|---|---|
domain required | string | A sending domain this workspace already has — read them from email_setup_get. Not any domain: this must be one Kairon sends from for this org. |
Price a sending domain and its mailboxes. The figures come from Instantly simulating the real order, so they are exact — and nothing is bought, no card is touched. Show the operator dueNow and pass that same number back as agreedTotal if they say yes; the purchase re-prices and refuses if it moved. One to five mailboxes.
| Name | Type | What it is for |
|---|---|---|
domain required | string | The domain name to buy and send from, like acme-outreach.com. Only .com and .org can be registered, and a name carrying a well-known trademark is refused. Not the customer's real website — a sending domain is a separate name, so a problem with it never touches their main domain's reputation. |
mailboxes required | object[] | The mailboxes to create on the domain — one to five, each a real person on the team, since a recipient who replies is replying to that name. More mailboxes means more sending capacity and more monthly cost. |
forwardingDomain | string | The customer's real website, where a visitor to the sending domain is sent — acme.com. Worth setting: a sending domain that resolves to nothing is exactly what a suspicious recipient checks. Any extension, not just.com/.org. Ask the operator; never guess it from their email address. |
Add a mailbox the customer ALREADY owns — on their own domain, in their own Google Workspace or Microsoft account — so Kairon can send from it. Needs an APP PASSWORD, not the account password: for Google that is an app password created in their Google account, and 2-step verification must be on. Ask the operator for it directly; never guess one. Adding the same address twice is safe and creates nothing. The reply says whether the domain's DNS records (MX, SPF, DKIM, DMARC) pass yet — they often do not immediately, which is normal and does not undo the mailbox. Then call email_warmup_start.
| Name | Type | What it is for |
|---|---|---|
email required | string | The full address of a mailbox the customer already owns, like ada@acme.com. |
firstName required | string | The mailbox owner's first name — it appears in the From line of every send. |
lastName required | string | The mailbox owner's last name. |
provider required | "google" | "microsoft" | "custom" | Who hosts the mailbox. google for Google Workspace or Gmail, microsoft for Microsoft 365 or Outlook — both fill in their own server settings. custom for anything else, and then all four host and port fields are required. |
password required | string | An APP PASSWORD for the mailbox, not the account password. For Google the operator creates one in their Google account with 2-step verification on. Ask them for it directly and never invent one; it is passed straight to the sending provider and stored nowhere in Kairon. |
smtpHost | string | Outgoing mail server, custom provider only (Google and Microsoft are known). |
smtpPort | integer | Outgoing mail port, custom provider only — usually 465 or 587. |
imapHost | string | Incoming mail server, custom provider only. |
imapPort | integer | Incoming mail port, custom provider only — usually 993. |
Ask Kairon to set this workspace up for email sending (plan), or to add enrichment credits (credits). Owner or admin only. Neither is charged automatically: a person at Kairon completes the purchase, and you will see it done when the workspace appears or the credit balance rises — so tell the operator it is being set up, not that it is ready. Asking twice for the same thing is refused; ask email_setup_get first to see what is already open.
| Name | Type | What it is for |
|---|---|---|
kind required | "plan" | "credits" | plan to have Kairon set this workspace up for email sending at all; credits to add enrichment credits, which are what finds a prospect's email address. Ask for plan first — credits are useless without somewhere to send from. |
quantity | integer = 1 | How many credit packs are wanted. Ignored for plan — there is one workspace. |
How this workspace sends email: emailProvider first — instantly, unipile (each person sends from their own Gmail/Outlook mailbox, connected by that person in Settings > Integrations > Email; there is nothing to buy or warm up), or null (no email yet). For an Instantly org it then says whether it is on Kairon's sending plan, which sending domains it has and what each mailbox is doing (still being created, warming up, sending, or in trouble), how much of the plan is used, how many enrichment credits are left, and anything already requested from Kairon. Read this FIRST before adding a domain or a mailbox — it says whether the shop is even open for this org; the domain, mailbox and plan tools are Instantly-only.
Takes no parameters.
Start warming up every mailbox on this sending domain. Warmup is what builds the reputation that keeps mail out of spam, and it takes about three weeks — Kairon attaches a mailbox to campaigns by itself once it is ready, so nothing else is needed after this. Safe to call twice: a mailbox already warming is left exactly as it is. If the mailboxes do not exist yet the reply says pending, which means a domain order is still being provisioned — wait and call again.
| Name | Type | What it is for |
|---|---|---|
domain required | string | A sending domain this workspace already has — read them from email_setup_get. Not any domain: this must be one Kairon sends from for this org. |
One axiom in full, as markdown, by a slug from framework_list. It is the argument and what to do about it, in the author's own words. Use it to ground advice and to explain WHY a playbook step exists; quote its reasoning to the operator in plain language, never the slug. Pair with skill_get for the how and doc_get for what Kairon itself does.
| Name | Type | What it is for |
|---|---|---|
slug required | string | Which axiom to read — a slug from framework_list. |
Kairon's go-to-market theory: the standalone truths every playbook rests on, each a slug, an area (belief-system, targeting, outreach, content, loop) and a title that says exactly what is inside. framework_get returns one in full. Read one when you need the WHY behind a step a playbook prescribes, when the operator questions the approach, or when you are about to advise on strategy rather than run a job. Titles only — cheap to list once per session.
Takes no parameters.
One playbook in full, as markdown, by a name from skill_list — call that first; an invented name is a 404. Read it BEFORE doing the job it covers, never after drafting: it encodes what Kairon measured to work, which is usually NOT what a general model would choose, so a draft written first is already wrong in the ways the playbook exists to prevent. Pair a writing skill with writing_check_list for the rules the draft is held to, and config_get for the operator's offer. Every playbook says WHAT to do; talking-to-the-operator says how to say it back to a founder who is not an engineer — fetch that one too if you have not already.
| Name | Type | What it is for |
|---|---|---|
name required | string | Which playbook to fetch — a name from skill_list. |
Kairon's playbooks — how to do each job well: onboarding, Sales Navigator, list building, campaigns, writing messages and posts, what to do next. Each row is a TEASER, not the craft: it hints at what a playbook covers so you can choose, and the instructions live only in the body. Never act on a row alone — including its own "pair with config_get" advice, which applies AFTER you skill_get that name, not instead of it. The catalogue grows without a release; never assume you know it.
Takes no parameters.
Kairon stops delivering to it at once. Its delivery history stays, so what it already received can still be read. To pause one instead, use webhook_set { id, status: "disabled_by_user" }.
| Name | Type | What it is for |
|---|---|---|
id required | string |
The recent deliveries to one endpoint, newest first: which event, when, whether it landed, how many attempts it took, and the status code the endpoint answered with. This is what answers 'my integration is not working' — a succeeded row means we delivered and their server accepted it.
| Name | Type | What it is for |
|---|---|---|
endpointId required | string | The endpoint whose recent deliveries to read. |
Every URL this organization has asked Kairon to POST events to, with the event types it wants, whether it is on, and how its last delivery went. The signing secret is NOT here — it is shown once, when the endpoint is created. An endpoint reading disabled_by_kairon was switched off by us after five days of total failure; turning it back on is webhook_set { id, status: 'enabled' }.
Takes no parameters.
Without id, registers a new endpoint and returns its signing secret — **the only time that secret is ever shown**, so hand it to the operator immediately and tell them we cannot show it again. Before you write the code that RECEIVES these requests, read doc_get { slug: "webhooks" }: the signature check, the raw-body rule and what to answer are not guessable. With id, changes that endpoint. The URL must be https and must resolve to a public address: localhost, a private range and a cloud metadata address are all refused, and the refusal says which rule it broke. Use status to turn an endpoint off without losing its history.
| Name | Type | What it is for |
|---|---|---|
id | string | Omit to register a new endpoint. Present to change that one. |
url | string | Where to POST. Must be https and must resolve to a public address. Required when creating. |
description | string | The operator's own label for it, e.g. 'Our HubSpot sync'. |
enabledEvents | "lead.stage.changed"[] | Which event types to send. Defaults to all of them when creating. |
status | "enabled" | "disabled_by_user" | Turn it off without deleting it, or back on. Re-enabling clears its failure streak. |
Sends one sample lead.stage.changed (an invented person) to the endpoint right now, signed like a real event, and returns what the endpoint answered: attempt.statusCode (null when nothing answered) and attempt.error. Use it after writing or changing the receiver; if it does not answer 2xx, fix the receiver and test again, but stop after a few tries and report the status code. The sample lead id is always lead_00000000000000000000000000, so a receiver can accept it without storing it. One try, never retried, and it never counts toward the endpoint being switched off. The body it sent is in doc_get { slug: "webhooks" } as the example.
| Name | Type | What it is for |
|---|---|---|
id required | string | The endpoint to send the sample event to. |
Change the organization's own display name, exactly as it appears across the Kairon app. Owner/admin only — a caller on any other role is refused. Separate from config_set because it carries its own, stricter permission rather than the open one every config section shares.
| Name | Type | What it is for |
|---|---|---|
name required | string | The workspace's new display name. |
Run one draft past the SAME judge a real send goes through, and get a pass/fail plus a reason for every rule that governs it. Nothing is sent, drafted or saved. Use it to try a rule the operator just wrote before they rely on it, and to show them WHY a draft fails — a rule that reads well and cannot be satisfied is the failure this exists to catch. Pass context (the post being answered, the thread) or rules about their words cannot be judged at all. Costs one model call.
| Name | Type | What it is for |
|---|---|---|
draft required | string | The exact text to judge, as it would go out. |
surface required | "post" | "comment" | What kind of writing this is. It decides which rules apply: general plus this section and nothing else. Messages are not checked, so there is no dm. |
context | string | What the writer was working from: the post a comment answers, the thread a reply continues, who the message is to. Rules like "does not echo their words back" or "reacts to something specific in the post" CANNOT be judged without it — omit it and they are judged on the draft alone, which reads as a pass. |
template | string | The operator's own template seed, when this draft was written from one. Its fixed text is then exempt from the style rules (the writer may only fill the slots, so failing it for the operator's own words asks it to fix what it may not touch), and template fidelity is measured against it. |
The rules Kairon holds posts and comments to, in three sections: general (both), post, comment. Messages, invites and replies are not checked, so there is no dm. Each returns Kairon's own shared rules, with the key you switch them by and whether they are on, then the Author's own rules with their ids. recentFailures counts drafts each rule stopped in the last 7 days — a high one is a rule nothing can satisfy. Read this before drafting anything.
| Name | Type | What it is for |
|---|---|---|
sections | "general" | "post" | "comment"[] | Read only these sections. Omit for all three. general applies to posts and comments; the other two govern that surface alone. Messages have no section: they are not checked. |
Replace the Author's OWN rules for ONE section, whole. Kairon's shared rules are untouched — switch those with writing_check_shared_set. Send the COMPLETE list for that section: keep a rule by including it with its id from writing_check_list (an id we do not recognise is replaced with a fresh one, which resets what that rule has stopped), add one by omitting the id, delete one by leaving it out. Other sections are untouched. Phrase a rule so a draft either passes it or does not.
| Name | Type | What it is for |
|---|---|---|
section required | "general" | "post" | "comment" | Which section to replace. A writer is judged against general plus its own section and nothing else, so a rule about comments belongs in comment, never in general. |
checks required | object[] | The COMPLETE desired list for that section — it REPLACES what is stored. Include every rule to keep, each with its id from writing_check_list so its history survives, plus any new ones without an id. Omit a rule to delete it. An empty array clears the section. At most 20; Kairon's own rules do not count against it. |
Switch one of Kairon's SHARED rules on or off for this Author, by a key from writing_check_list. Kairon's rules are on by default and are not editable — an Author only chooses whether one applies — so this is the only thing to do to one. Use it when the operator's own style genuinely conflicts with a built-in rule, not to quiet a rule that keeps stopping drafts: that one is usually telling the truth about the writing.
| Name | Type | What it is for |
|---|---|---|
key required | string | A shared rule's key from writing_check_list (e.g. comment.no_first_name_opener). A key that is not a real shared rule is refused rather than stored. |
enabled required | boolean | false switches Kairon's rule off for this Author; true switches it back on. |
The Author's comment style: free text Kairon follows every time it writes a comment for them — their real comments as examples, how they use emoji, their kind of English. It guides the writer and is never judged; the comment rules from writing_check_list are what is enforced. Empty text means none is set. Read it before writing a comment in their voice.
Takes no parameters.
Replace the Author's comment style, whole. Every comment Kairon drafts for them follows it, in every campaign, with no step to edit. Best built from their REAL comments (profile_comments on their own profile): paste 15-20 as examples, then note how they use emoji, how they put themselves in and what their English is like. A rule that must hold every time goes in writing_check_set instead. An empty string removes it.
| Name | Type | What it is for |
|---|---|---|
text required | string | The COMPLETE comment style — it REPLACES what is stored. Put the Author's real comments in as examples, then how they use emoji, how they bring themselves in and what their English is like. Guidance, never judged: anything that must hold every time belongs in a comment rule instead. An empty string removes it. At most 8000 characters. |
Troubleshooting
No tools appear after connecting
The account behind the connection has no LinkedIn seat, or the plan is not active. Connect a seat in Kairon and connect again. This is the common one.
Your assistant says a tool refused
Refusals are deliberate and they say why: a daily cap reached, an invite already pending, a message that can no longer be edited. The reason comes back as a fixed code, so your assistant can tell "wait and retry" apart from "this will never work".
The connection seems to drop
It should not. Every call signs in again and finishes on its own, so there is no session to expire and nothing to reconnect after a deploy or after you close your laptop. If tools disappear, check the two causes above first.
ICP creation or qualification fails
The MCP can create and update ICPs with icp_create and icp_update. Judging is list_qualify, the one tool that spends your AI balance — tell the operator roughly what a run costs before you start it. Read the tool error first: missing criteria, stale versions and exhausted qualification allowance each return a specific reason.
{
"isError": true,
"content": [{ "type": "text", "text": "…" }]
}
{
"code": "CHANNEL_ACTION_LIMIT_EXCEEDED",
"params": { "retryAfterSeconds": 41400 }
}
Support
Ask Kairon, the guide inside the app, answers setup questions and knows your account. If you are stuck on the connection itself, sign in and ask it there.
Building against Kairon rather than talking to it? Every tool with its full JSON Schema, exactly as the server returns it: mcp-tools.json