Kairon
Build prospecting lists from buying signals, enrich them, and run LinkedIn outreach.
Documentação
Getting started
- Step 1
Create a Kairon account
Sign up and finish onboarding. It takes about 10 minutes and you need an active plan. 2. 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. 3. 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. 4. 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. 5. Step 4b
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/mcpnpm 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?
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.
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 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| typerequired | "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. |
| queryrequired | 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. |
Create an ICP — a target thesis spanning the companies to reach and the people inside them — at 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.
| Name | Type | What it is for |
|---|---|---|
| namerequired | string | A short, specific name — the segment, not the pitch. |
| definitionrequired | object | The ICP definition. On icp_update this REPLACES the previous one wholesale, so a field you leave out is cleared, not carried forward — read the ICP first and send back everything you do not mean to change. Use null for a side with no criteria and [] for no checks. AI CHECKS: normally ONE per side — an identity fact confirmable from PUBLIC data, phrased so "met" reads as good. One that cannot be confirmed reads as not_met and disqualifies, emptying the ICP. Read the icp-definition skill. |
Soft-delete an ICP. Returns referencedByCampaigns — how many campaigns referenced it as their enrollment gate (any status, draft included) and no longer have one. The delete always proceeds, so check that number and tell the operator if it is not zero: any of those campaigns that is running just widened its audience.
| Name | Type | What it is for |
|---|---|---|
| icpIdrequired | string |
Read one ICP and its latest (active) version by icpId.
List this org's ICPs, each with its latest (active) version's criteria and conditions.
Takes no parameters.
Qualify one LinkedIn person or company against a saved ICP version and PERSIST the verdict: **you** judge the AI checks, Kairon judges the structured filters and owns the row. Call icp_get first — its conditions are the checks to answer, and their ids are what results[].criterionId must carry. Answer EVERY check of the sides judged or the call is refused, never scored: a missing answer reads as a rejection. rationale is required. Pace a large audit against the returned headroom.
| Name | Type | What it is for |
|---|---|---|
| targetrequired | 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. |
| icpVersionIdrequired | string | |
| results | object[]= [] | One entry per criterion you judged, keyed by criterionId from the ICP version. |
Revise an ICP: mints a new immutable version, which becomes the active one. This **replaces** the definition wholesale, so a field you leave out is cleared, not carried forward — call icp_get first and send back the complete definition, changing only what you mean to change. Past versions stay readable, so earlier verdicts remain explainable. **Read the icp-definition skill** before you change the AI checks — it decides whether the new version still qualifies anyone.
| Name | Type | What it is for |
|---|---|---|
| icpIdrequired | string | |
| namerequired | string | A short, specific name — the segment, not the pitch. |
| definitionrequired | object | The ICP definition. On icp_update this REPLACES the previous one wholesale, so a field you leave out is cleared, not carried forward — read the ICP first and send back everything you do not mean to change. Use null for a side with no criteria and [] for no checks. AI CHECKS: normally ONE per side — an identity fact confirmable from PUBLIC data, phrased so "met" reads as good. One that cannot be confirmed reads as not_met and disqualifies, emptying the ICP. Read the icp-definition skill. |
Is this company hiring? A published vacancy PROVES a need, dated and linked — the strongest buying signal for anyone selling staffing, recruitment or talent. Sweep a market with region + what you are looking for (this DISCOVERS companies, it does not filter yours), or check one with company, where zero postings is a real answer and not an error. region is required for a sweep: without it LinkedIn silently returns your own country only. Prefer roles over keywords — a job-title id matches the ROLE while a keyword matches anywhere in the ad, and on the same sweep that was 17 postings against 1241. Seniority is seniority, NEVER the word "Senior" in keywords: LinkedIn ORs keywords, so that widens the search (1241 → 2480) while looking like it narrows it. Hits are NOT deduped — one company often posts one need many times.
| Name | Type | What it is for | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| keywords | string | What the company is hiring FOR — a product, stack or role ("Oracle", "NetSuite", "SAP HCM"). This is the market sweep: it returns every company hiring that profile, so it DISCOVERS companies rather than filtering ones you already hold. Requires region unless company is also set. A product name usually needs no translation — "Oracle" catches Portuguese and Spanish postings alike. | |||||||||
| region | string | The country or region to sweep, in plain language ("Brazil", "Mexico") — resolved to LinkedIn's id for you, and echoed back so a wrong match is visible. REQUIRED for a keyword sweep: without it LinkedIn silently returns only the seat's own country, which measured 33 results against 151 for the same search. Optional in company mode, where the question is not geographic. A bare numeric id is accepted and passes through. | |||||||||
| company | object | Check ONE company instead of sweeping a market: "is this company hiring, anywhere?". A provider_id is free; a url or slug costs one cache-first company lookup and the resolved id is echoed back, so hold it for next time. NO postings is a real answer here, not an error — it means this company is not hiring, which disqualifies a lead with a reason. | |||||||||
| datePosted | integer | How many days back to look. A sweep defaults to 30 days, which is almost always right: LinkedIn removes a posting when the role closes, so 90 days measured 170 postings against 151 at 30 — to find MORE companies repeat the sweep in a few weeks rather than widening it. A COMPANY CHECK has no default window: "is this company hiring" is asked of everything still open, so an empty answer means "not hiring" rather than "nothing lately". | |||||||||
| limit | integer | Page size, default 50 (the provider maximum). One page costs one metered action whatever its size, so a small page buys nothing. The response carries total: compare it to the postings you hold and keep calling with the returned cursor until cursor is null. | |||||||||
| roles | string[] | Job TITLES from LinkedIn's own taxonomy, in plain language ("Product Analyst", "Head of Growth") — resolved to title ids for you and echoed back. This is the sharpest filter this tool has and usually beats keywords: measured on the same Argentina sweep, keywords: "Product Analyst" returned 1241 postings while roles: ["Product Analyst"] returned 17, because keywords match anywhere in a posting while a title id matches the ROLE. LinkedIn treats several titles as OR — pass the variants you mean ("Product Analyst", "Senior Product Analyst"). | |||||||||
| functions | string[] | Job FUNCTIONS — the department a role belongs to ("Product Management", "Engineering", "Marketing"), resolved to LinkedIn ids and echoed back. Much wider than roles: Product Management alone returned 207 postings where the "Product Analyst" title returned 17. Reach for it to size a department across a market; reach for roles to find one job. | |||||||||
| industries | string[] | The HIRING COMPANY's industry, in plain language ("Financial Services", "Software Development") — resolved to LinkedIn ids and echoed back. This filters by the employer, not by the role, so it is how a sweep is aimed at the market you actually sell to. | |||||||||
| seniority | "executive" | "director" | "mid_senior" | "associate" | "entry" | "intern"[] | LinkedIn's EXPERIENCE LEVEL on the posting. This is the ONLY way to ask for seniority — putting "Senior" in keywords does the opposite of what it looks like, because LinkedIn ORs the words: the same sweep went from 1241 hits to 2480, wider, not narrower. Several levels are OR ("senior and above" is ["mid_senior","director","executive"], measured 675 against a 1241 baseline). | |||||
| jobType | "full_time" | "part_time" | "contract" | "temporary" | "volunteer" | "internship" | "other"[] | LinkedIn's JOB TYPE — the employment arrangement. Several are OR. | ||||
| presence | "on_site" | "hybrid" | "remote"[] | On-site / hybrid / remote, as the posting declares it. Several are OR. | ||||||||
| underTenApplicants | boolean | LinkedIn's UNDER 10 APPLICANTS. The sharpest recency filter there is — it says the posting is not just recent but still UNWORKED, and it cut the same 1241-hit sweep to 127. Use it when the value of the signal is getting there first. | |||||||||
| inYourNetwork | boolean | LinkedIn's IN YOUR NETWORK — only companies where the seat already has a connection. Warm ground: it cut the same sweep from 1241 to 265, and every hit comes with a path in. | |||||||||
| easyApply | boolean | LinkedIn's EASY APPLY. | |||||||||
| hasVerifications | boolean | LinkedIn's HAS VERIFICATIONS — the poster verified their identity. | |||||||||
| fairChanceEmployer | boolean | LinkedIn's FAIR CHANCE EMPLOYER. | |||||||||
| benefits | "medical_insurance" | "vision_insurance" | "dental_insurance" | "disability_insurance" | "401(k)" | "pension_plan" | "paid_maternity_leave" | "paid_paternity_leave" | "commuter_benefits" | "student_loan_assistance" | "tuition_assistance"[] | LinkedIn's BENEFITS, as the posting declares them. Several are OR. |
| commitments | "career_growth_and_learning" | "diversity_equity_and_inclusion" | "environmental_sustainability" | "social_impact" | "work_life_balance"[] | LinkedIn's COMMITMENTS, as the company declares them. Several are OR. | ||||||
| minimumSalary | object | LinkedIn's SALARY floor. Both fields are required together, and value is in THOUSANDS from a fixed ladder LinkedIn will not interpolate — dollars (USD/AUD/CAD) step 40…200, pounds (GBP) step 20…100. Off-ladder numbers are refused rather than rounded. | |||||||||
| cursor | string | The opaque forward cursor from the previous page. null came back = no more. | |||||||||
| intoList | string | Where this page's HIRING COMPANIES are appended — always a companies list, because a job posting surfaces a company, never a person. Free: the postings already carry each company's identity, so nothing is re-fetched. A company hiring for several roles is appended once. | |||||||||
| 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. |
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 a company addressed by universal name or numeric id. Returns complete data, metered and cached (refresh: true forces a live read), plus headroom — what is LEFT on this seat. Budget a large audit against the SMALLEST number in it. For a person, withRecommendations: true also returns who vouched for them — each recommender named and addressable, the strongest warm-intro path LinkedIn publishes.
| Name | Type | What it is for |
|---|---|---|
| targetrequired | 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 this seat's 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 |
|---|---|---|
| assetrequired | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
| idrequired | string | |
| itemsrequired | 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. Build membership with list_add.
| Name | Type | What it is for |
|---|---|---|
| assetrequired | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
| namerequired | string | The list's name, for the operator to recognise it. |
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 |
|---|---|---|
| assetrequired | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
| idrequired | 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.
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 |
|---|---|---|
| assetrequired | "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 ({ asset, id, q?, tags?, cursor?, limit? }), newest-added first. Each row is { id, title, subtitle, location, linkedinUrl, target, company, companyId, companyRecordId, values, tags, owner } — the id removes it with list_remove or writes with list_set, values holds this list's custom columns, tags the org-wide labels the person wears, owner who works it. A leads row carries its member's EMPLOYER — company is the name, companyId LinkedIn's own id, the same one a people-search hit reports — so grouping or pruning a list you already hold needs no search re-run. To load a member, pass its target to linkedin_fetch AS-IS: it carries the right handle and its own kind. Do NOT fetch by linkedinUrl — that is the human link, null for anyone found through Sales Navigator, who is fetchable all the same. tags in the query keeps only members carrying EVERY tag named. Page with the returned cursor.
| Name | Type | What it is for |
|---|---|---|
| assetrequired | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
| idrequired | 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. |
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 |
|---|---|---|
| assetrequired | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
| idrequired | string | |
| itemsrequired | 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.
| Name | Type | What it is for |
|---|---|---|
| assetrequired | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
| idrequired | string | |
| itemsrequired | 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.
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.
| Name | Type | What it is for |
|---|---|---|
| assetrequired | "leads" | "companies" | Which kind of list: 'leads' (people) or 'companies'. |
| idrequired | string | |
| namerequired | 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. |
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 |
|---|---|---|
| assetrequired | "leads" | "companies" | Which kind of row these items are — people or companies. |
| itemsrequired | object[] | The rows to assign, each naming its subject by ONE of memberId, url, or providerId. |
| ownerrequired | 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 everyone your campaigns reached now stands. Returns a count for each stage — contacted, replied, interested, meeting_booked, won, lost — and, when you name a stage, that column's people with their owner and when they were last 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 | "contacted" | "replied" | "interested" | "meeting_booked" | "won" | "lost" | 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: contacted, replied, interested, meeting_booked, won, or lost. 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 replies, so use this for the judgment calls it cannot make — a meeting booked, a deal won or lost. Never creates a lead: an unknown url comes back not_found.
| Name | Type | What it is for | ||||
|---|---|---|---|---|---|---|
| itemsrequired | object[] | The people to move, each naming its subject by ONE of memberId, url, or providerId. | ||||
| stagerequired | "contacted" | "replied" | "interested" | "meeting_booked" | "won" | "lost" | Where they now stand. The ladder runs contacted → replied → interested → meeting_booked → won | lost. You may move someone backward as well as forward. |
Everyone who commented on or reacted to ONE named post, by its LinkedIn URL — the audience a post already earned, each person carrying HOW they engaged (their comment text, or which reaction), which is the warmest opening line outreach gets. Use this when you have the post; use signal_search with engaged_with_profile when you want whoever keeps engaging with a profile. A shortened lnkd.in link carries no post id — expand it first. Comments are walked to exhaustion, then reactions; pass intoList with exhaust to sweep the whole post into a list in one call, and expect several metered reads.
| Name | Type | What it is for |
|---|---|---|
| urlrequired | string | The post — its LinkedIn permalink (…/posts/… or …/feed/update/…) or its bare urn:li:activity|ugcPost |
| intoList | string | Where this page's PEOPLE are appended — always a leads list. Only the person is added; the post and how they engaged are why they are here, not members. Pair with exhaust to sweep the whole post in one call. |
| cursor | string | |
| 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. |
A page of a profile's recent posts (transient — not persisted). Address the person with target — { type, value } (url, public_identifier, or provider_id). Use cursor to page; one page is one metered action.
| Name | Type | What it is for |
|---|---|---|
| targetrequired | object | Who this acts on — one LinkedIn person or company, named by URL, vanity slug, or provider id. |
| cursor | string |
Classic LinkedIn people search — the door for a seat with NO Sales Navigator of its own (identity_get → capabilities.salesNavigator: false) asking something only that seat can answer: networkDistance, connectionsOf and followersOf are measured from the operator here, and shared capacity REFUSES them. **Holding an own Sales Navigator seat? Use search_sales_navigator for every people search, degree included** — it does what this does and adds the structured filters, and coming here trades them for a noisier answer. Takes keywords, id-resolved filters, network distance and language; or paste a search url to run verbatim. Pass intoList to append the page.
| Name | Type | What it is for | |
|---|---|---|---|
| url | string | ||
| keywords | string | ||
| cursor | string | ||
| limit | integer | Page size. Defaults to 10 when sampling, 50 when intoList is set — previewing an audience and collecting one are different jobs. Classic search is clamped to 50 provider-side. COLLECTING AN AUDIENCE? The response carries total: compare it to the hits you have, and keep calling with the returned cursor and the SAME intoList until cursor is null. Pages append server-side, so the list grows without hits crossing your context, and one page costs one metered action whatever its size. | |
| filters | object[] | ||
| networkDistance | 1 | 2 | 3[] | Connection degree: 1 (1st), 2 (2nd), 3 (3rd+). LinkedIn native filter CONNECTIONS. |
| profileLanguage | string[] | Profile languages as 2-letter ISO-639-1 codes (e.g. ["en","es"]). | |
| openTo | "proBono" | "boardMember"[] | ||
| connectionsOf | string[] | LinkedIn provider ids (ACoAA…, from people.suggest — NOT numeric member ids) whose connections to search within. Aimed at someone you are NOT connected to, this returns your MUTUAL connections with them — the people who could introduce you. | |
| followersOf | string[] | LinkedIn provider ids (ACoAA…, from people.suggest — NOT numeric member ids) whose followers to search within. | |
| advancedKeywords | object | ||
| intoList | string | ||
| 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. |
Search Sales Navigator over people OR companies. **Holding your own Sales Navigator seat (identity_get → capabilities.salesNavigator: true)? This is the door for EVERY people search** — a connection degree is a structured filter like any other, so "my 1st-degree X" belongs here with networkDistance, keeping the role / seniority precision classic search cannot express. **No seat of your own is no reason to leave either**: Kairon falls back to shared capacity itself, and only then — on that borrowed seat — are networkDistance and connectionsOf refused and search_people the answer. Prefer a structured filter over keywords throughout, because filters are precise where keywords are noisy. Pass human-readable names ({ type, query }); the server resolves ids. Same-dimension OR, different dimensions AND. **Read the sales-nav-search skill first.** One page is one metered action.
| Name | Type | What it is for | ||||||
|---|---|---|---|---|---|---|---|---|
| category | "people" | "companies"= "people" | Search people (default) or companies. Some fields are valid for only one category. | ||||||
| keywords | string | LAST resort — matches noisily across all text. Prefer a structured filter for any title/industry/geography/size intent; use keywords only for a concept no filter captures. This is a BOOLEAN query, not a phrase: bare space-separated words are AND-ed (ALL must appear), so "fintech payments neobank" requires all three together and usually returns ZERO. For ALTERNATIVES, OR them, quote each, and ALWAYS wrap the OR group in parentheses: ("fintech" OR "payments" OR "neobank"). The parentheses are MANDATORY — LinkedIn silently mis-parses an unparenthesized OR of 4+ terms and returns ~0. Quote multi-word phrases; combine groups with AND/NOT (e.g. ("fintech" OR "neobank") AND "payments"). Operators must be UPPERCASE. | ||||||
| filters | object[] | Id-resolved dimensions (location, industry, company, …). Same dimension repeated = OR; different dimensions = AND (each narrows the results). | ||||||
| cursor | string | Pass the cursor from a prior result to page. | ||||||
| limit | integer | Page size. Defaults to 10 when sampling, 100 when intoList is set — previewing an audience and collecting one are different jobs. Sales Navigator allows up to 100. COLLECTING AN AUDIENCE? The response carries total: compare it to the hits you have, and keep calling with the returned cursor and the SAME intoList until cursor is null. Pages append server-side, so the list grows without hits crossing your context, and one page costs one metered action whatever its size. | ||||||
| firstName | string | |||||||
| lastName | string | |||||||
| role | object | Current job-TITLE keywords (include/exclude) — the primary people filter. Use it for ALL title intent, NOT keywords and NOT seniority. Each include entry is a separate Boolean title clause LinkedIn ORs. Two kinds: (1) single-title pills — exact exec titles + founder, one entry per variant (e.g. "CEO","Chief Executive Officer","Founder","Fundador"); (2) ONE seniority-block pill for broad leadership roles: a marker AND a parenthesized OR-list of role synonyms, e.g. <<SENIORITY_BLOCK_EN>> AND ("growth" OR "demand generation"). The marker (one of <<SENIORITY_BLOCK_EN>> / <<SENIORITY_BLOCK_ES_EN>> / <<SENIORITY_BLOCK_PT_EN>>, by language) expands server-side to a broad leadership OR-block so you recall heads/VPs/directors/managers without enumerating titles. See the playbook for the full recipe. | ||||||
| seniority | object | LinkedIn's coarse seniority ladder (cxo, vice_president, director, senior, …) — NOT job titles. Use only when you mean a seniority level; pair with role for "VP-level whose title contains X". | ||||||
| tenure | object[] | Years at the current company in their current role, as {min,max} ranges. Years MUST be LinkedIn bucket breakpoints — valid min: 0, 1, 3, 6, 10; valid max: 1, 2, 5, 10 (buckets <1, 1-2, 3-5, 6-10, 10+). Any other value (e.g. min:5 or min:2) is rejected. So "5+ years" → {min:6}; "3+ years" → {min:3}; "3 to 5 years" → {min:3,max:5}. | ||||||
| tenureAtCompany | object[] | Total years at the current company, as {min,max} ranges. Years MUST be LinkedIn bucket breakpoints — valid min: 0, 1, 3, 6, 10; valid max: 1, 2, 5, 10 (buckets <1, 1-2, 3-5, 6-10, 10+). Any other value (e.g. min:5 or min:2) is rejected. So "5+ years" → {min:6}; "3+ years" → {min:3}; "3 to 5 years" → {min:3,max:5}. | ||||||
| tenureAtRole | object[] | Years in the current role, as {min,max} ranges. Years MUST be LinkedIn bucket breakpoints — valid min: 0, 1, 3, 6, 10; valid max: 1, 2, 5, 10 (buckets <1, 1-2, 3-5, 6-10, 10+). Any other value (e.g. min:5 or min:2) is rejected. So "5+ years" → {min:6}; "3+ years" → {min:3}; "3 to 5 years" → {min:3,max:5}. | ||||||
| companyHeadcount | "1-10" | "11-50" | "51-200" | "201-500" | "501-1000" | "1001-5000" | "5001-10000" | "10001+"[] | Size band of the person's current employer (people search). For a company's OWN size use headcount. Approximate "startup" with small buckets (["1-10","11-50","51-200"]). |
| companyType | "public_company" | "privately_held" | "non_profit" | "educational_institution" | "partnership" | "self_employed" | "self_owned" | "government_agency"[] | Ownership type of the person's current employer (people search; e.g. privately_held, public_company). A companies search has no company-type facet. |
| availableForWork | boolean | null | Signals availability for a new role (#OpenToWork, or no current position). true requires it, false excludes it. Screened after the search, on loaded profiles only; unknown never counts as available. | ||||||
| profileLanguage | string[] | Profile languages as 2-letter ISO codes (e.g. ["en","es"]). | ||||||
| networkDistance | 1 | 2 | 3 | "GROUP"[] | Connection degree from the seat: 1 / 2 / 3 — valid for BOTH people and companies. The "GROUP" value (shared group) is PEOPLE only; do not use it on a companies search. Measured from the SEAT running the search, not from the operator. A degree IS a structured filter, so with the org's OWN Sales Navigator seat this is the right place to ask for "my 1st-degree X" — you keep the role / seniority precision classic search cannot express. Do NOT downgrade to search_people for it. identity_get says whether that seat exists (capabilities.salesNavigator). Only when it is false does the search run on shared capacity, where this filter is REFUSED — that is the case where classic search_people with networkDistance is the route, because it always runs on the operator's own seat. Never treat a Sales-Nav hit as connected: confirm before building a campaign that sends a direct message. | ||||
| connectionsOf | string[] | LinkedIn provider ids (ACoAA…, from people.suggest — NOT numeric member ids) whose connections to search within. Aimed at someone you are NOT connected to, this returns your MUTUAL connections with them — the people who could introduce you — matching the shared_connections_count on their profile. networkDistance defaults to [1] and must STAY [1] for such a target: at any other degree Sales Navigator silently discards this filter and answers with your entire 2nd-degree network, which looks like a real result. The one exception is a target you ARE connected to, where [2] validly returns everyone they know that you do not. Looking for an introduction to someone? Read the warm-intro skill first — this filter is one step of it, and the rest decides which of the people it returns can actually help. Like networkDistance this is relative to the SEAT, and works here whenever the org holds its OWN Sales Navigator — identity_get's capabilities.salesNavigator says whether it does. Only when that is false does the search run on shared capacity, where this filter is REFUSED and classic search_people is the route instead. | ||||||
| changedJobs | boolean | Spotlight: only people who recently changed jobs. | ||||||
| postedOnLinkedin | boolean | |||||||
| mentionedInNews | boolean | |||||||
| followingYourCompany | boolean | |||||||
| pastColleague | boolean | Spotlight, relative to YOU: only people you actually worked with. Combined with connectionsOf it answers "which of this person's connections are my own former colleagues" — the shortest warm path there is. Expect a very small result; that is the point, not a misfire. | ||||||
| sharedExperiences | boolean | Spotlight, relative to YOU: only people who share a school, employer or group with you. The wider net beneath pastColleague, and the same combination with connectionsOf applies. The response never says WHICH experience is shared — membership in the result is the signal; load a profile to name the overlap. | ||||||
| viewedYourProfileRecently | boolean | |||||||
| viewedProfileRecently | boolean | |||||||
| messagedRecently | boolean | |||||||
| includeSavedLeads | boolean | |||||||
| includeSavedAccounts | boolean | |||||||
| hasJobOffers | boolean | Company search: only companies currently hiring. | ||||||
| headcount | "1-10" | "11-50" | "51-200" | "201-500" | "501-1000" | "1001-5000" | "5001-10000" | "10001+"[] | The company's OWN size band (companies search; the analog of people-search companyHeadcount). Approximate "startup" with small buckets (["1-10","11-50","51-200"]). |
| headcountGrowth | object | Company headcount growth rate as a {min,max} percentage range. | ||||||
| annualRevenue | object | Company annual revenue {currency (ISO 4217), min, max} in millions. Both min AND max are required and MUST be breakpoints: 0, 0.2, 1, 2.5, 5, 10, 20, 50, 100, 500, 1000, 1001. | ||||||
| followersCount | object[] | Company follower count {min,max} ranges. Breakpoints — min: 1, 51, 101, 1001, 5001; max: 50, 100, 1000, 5000. | ||||||
| fortune | object[] | Fortune ranking {min,max} ranges (e.g. Fortune 500). Breakpoints — min: 0, 51, 101, 251; max: 50, 100, 250, 500. | ||||||
| recentActivities | "senior_leadership_changes" | "funding_events"[] | Company signals: senior_leadership_changes and/or funding_events. | ||||||
| departmentHeadcount | object | |||||||
| departmentHeadcountGrowth | object | |||||||
| savedAccounts | string[] | |||||||
| savedSearchId | string | |||||||
| recentSearchId | string | |||||||
| lastViewedAt | number | |||||||
| withinArea | number | |||||||
| intoList | string | |||||||
| 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. | ||||||
| companyLists | object[] | Scope a PEOPLE search to the companies in your own Kairon company lists (list_list, asset "companies"). The server expands each list to its companies' LinkedIn ids as a current_company filter — no per-company resolution, no 40-filter ceiling. NOT account_lists, which means Sales Navigator's own saved lists and will not match a Kairon list. People category only. |
Find people by what they DID, not by what their profile says — the behavioural counterpart to search_people and search_sales_navigator. Pick a signal (who engaged with a profile or a keyword, who posted one, the seat's newest connections, who viewed it); each takes its own config. Results are RAW — real people who really did this, with no ICP screening, so screen them yourself. Pass intoList to append the page. The list-building skill covers which signal answers which question.
| Name | Type | What it is for | |||
|---|---|---|---|---|---|
| signalrequired | "posted_with_keyword" | "engaged_with_keyword" | "engaged_with_profile" | "recently_connected" | "viewed_my_profile" | Which signal to run. It decides which config fields are read — a signal that takes no parameters ignores config entirely. |
| config | object | Parameters for the chosen signal. Fields the signal does not use are ignored. | |||
| intoList | string | Where this page's PEOPLE are appended — always a leads list, whichever signal ran, because every searchable signal answers with people. Only the person is added; the post and the engagement are why they are here, not members. | |||
| cursor | string | ||||
| 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. |
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 |
|---|---|---|
| listIdrequired | 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.
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.
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 |
|---|---|---|
| listIdrequired | 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 |
|---|---|---|
| listIdrequired | 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. |
| icpIdrequired | 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. |
| signalsrequired | 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. |
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 |
|---|---|---|
| idrequired | string |
Every tag this organization has, with its color. A tag is the operator's own label on a person or a company (warm, gatekeeper, met-at-event) and it follows them everywhere — unlike a list's custom column, which belongs to that one list. Read this before tag_set, which names tags by name and refuses one that does not exist.
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 |
|---|---|---|
| assetrequired | "leads" | "companies" | Which kind of row these items are — people or companies. |
| itemsrequired | object[] | The rows to tag, each naming its subject by ONE of memberId, url, or providerId. |
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 send window. Refused with CAMPAIGN_ACTIVATION_BLOCKED (409) unless all four preconditions hold — params.unmet names every one that does not, as data you can branch on. 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. |
| idrequired | 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 |
|---|---|---|
| idrequired | 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 seat (channelAccountId, a connected LinkedIn account in this org — seat_list returns the ones you can pick), optionally the ICP to gate enrollment on. Omit icpId and the org's most-used ICP becomes the gate, if it has one. 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 |
|---|---|---|
| namerequired | string | The campaign's name, for the operator to recognise it in a list. Not seen by any lead. |
| channelAccountIdrequired | string | The sending seat — a connected LinkedIn account in this org. Every send comes FROM it. Get the id 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. OMIT IT and the org's most-used ICP is auto-selected, which is almost always what the operator meant — pass one only when they named a different ICP. |
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 |
|---|---|---|
| idrequired | 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 |
|---|---|---|
| idrequired | string | The campaign id, as campaign_list or campaign_create returned it. |
| leadIdrequired | string | The lead to start, as campaign_runs or list_members returned its id. |
List this org's campaigns, newest first, keyset-paginated — { q?, status?, cursor?, limit? }. q matches the campaign name; status filters to one of draft | active | paused | archived; 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. |
| cursor | string | Opaque keyset token from the previous page's nextCursor. Omit for the first page. |
| limit | integer= 25 | Campaigns per page, 1-100. Results are 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.
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, an error where no message could be produced, and the send-verifier's verification verdict (advisory here — a preview never blocks). 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 |
|---|---|---|
| idrequired | string | The campaign id, as campaign_list or campaign_create returned it. |
| nodeKeyrequired | 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 |
|---|---|---|
| idrequired | 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 |
|---|---|---|
| runIdrequired | 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 |
|---|---|---|
| runIdsrequired | 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 |
|---|---|---|
| runIdrequired | string | One run's id, as campaign_runs returned it — a run is one enrolled lead. |
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.
Read the campaign's sequence runs — one per enrolled lead — keyset-paginated. bucket filters to one activity tab (waiting, didnt_accept, didnt_answer, replied, interested, skipped, error); omit it for every run. Each run carries the lead, where it currently stands, when it acts next, its outcome once terminal, and the error or skip detail where relevant. Page with the returned cursor. campaign_stats tells you which bucket is worth reading.
| 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. |
| nodeKey | string | Only runs currently sitting on this step, by the node key the graph gave it. | |||||
| cursor | string | ||||||
| limit | integer= 50 | ||||||
| idrequired | string |
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 |
|---|---|---|
| nodeRunIdrequired | 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. |
| bodyrequired | 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. |
| 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).
Replace the campaign's editable DRAFT sequence graph IN FULL — this overwrites, it is not a patch. Nodes are { key, type, config } with a stable key you choose; edges are { fromKey, toKey, condition } routing one node's outcome to the next. Layout is automatic. Which conditions a type emits, and which types it may connect to, are fixed rules — the edges field carries the table, and a violation is refused (400). **The campaign-building skill has the order to build a campaign in, and how to write the copy.** Saving is not publishing.
| Name | Type | What it is for |
|---|---|---|
| idrequired | string | The campaign id, as campaign_list or campaign_create returned it. |
| graphrequired | 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. |
Everything about how a campaign is performing, in one read. summary is the live snapshot — status, audience, what is queued, today's usage, the send window, the current version (null until campaign_publish has run, which is what to check before campaign_activate), and copy coverage, which is why queuedCount can sit far under audienceSize. funnel runs enrolled → invited → accepted → messaged → replied → interested. buckets counts where everyone stands.
| Name | Type | What it is for |
|---|---|---|
| idrequired | string | The campaign id, as campaign_list or campaign_create returned it. |
| days | 7 | 30 | Window for the daily activity series, 7 or 30, in the campaign's own local days. OMIT IT and the series is left out entirely rather than defaulted — do not pay to read one you were not asked for. |
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. 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. |
| dailyEnrollmentLimit | integer | How many leads may enroll per day, 1-200 (default 25). Caps the START of new runs, not sends — the org's own per-seat limits still apply on top. |
| 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. |
| idrequired | 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. |
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 |
|---|---|---|
| fromrequired | string | First day of the window, YYYY-MM-DD, inclusive. |
| torequired | 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 LinkedIn conversations, newest activity first. Defaults to YOUR OWN seat — pass scope: "org" for every seat in the organization, or seatId for one teammate. Filter by unread, leads-only, or q, which matches the counterpart's NAME and does not search message text. Each chat carries the id that chat_messages takes, the counterpart, any linked lead, and a last-message preview. 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. |
| leadsOnly | boolean | Only chats already linked to a lead. |
| 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. 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. 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 |
|---|---|---|
| chatIdrequired | string | |
| cursor | string | |
| limit | integer |
Send an InMail (optional subject) to the person target addresses. Requires the Sales Navigator seat and a live InMail credit; refused fast without either. 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 |
|---|---|---|
| targetrequired | 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. |
| textrequired | string | The InMail body, exactly as it will be sent. Nothing 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 |
|---|---|---|
| targetrequired | 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 |
|---|---|---|
| chatIdrequired | string | |
| messageIdrequired | 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 |
|---|---|---|
| chatIdrequired | string | |
| messageIdrequired | string | The message's id from chat_messages. |
| textrequired | 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 — 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. |
| textrequired | string | The message body, exactly as it will be sent. Nothing 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, how they are addressed there, and capabilities, what the seat can do. 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. capabilities.salesNavigator true means search_sales_navigator runs on THIS seat, so networkDistance / connectionsOf measure the degree from this person; false means shared capacity, which refuses both. linkedin holds the handle and display facts only; when hydrated is false, pass its publicIdentifier to linkedin_fetch for the full profile. seatId names your own account even when it is not currently sendable (one waiting on a reconnect or a 2FA checkpoint keeps its id here); seat_list is the seats that CAN send right now.
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 |
|---|---|---|
| userrequired | 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.
The LinkedIn seats this organization can send FROM — the same list the campaign wizard's sender picker offers, every one connected and ready. Call it BEFORE campaign_create: a seat's id is exactly what that tool takes as channelAccountId, and what chat_list takes as seatId. ownerName is the teammate it belongs to and isSelf marks your own; any member may send from any of them, and the chosen seat's owner is the campaign's author. hasSalesNavigator says whether an InMail step is legal on that seat. authCountry is the country it authenticates from — use it to open a send window on noon where the seat lives. An empty list means no seat here is ready to send from. If you expected one, ask the operator to contact support at [email protected]. Free: it reads nothing on LinkedIn and spends no daily limit.
What the operator has told Kairon: their content checks (what a good post must satisfy), their competitor and source URLs, their writing guidelines (voice), and their organization's value proposition, lead magnet and learnings log. Read this FIRST when deciding what they should work on next, and ground every draft in it — a message written without it is generic. Name sections to read only some.
| Name | Type | What it is for | |||||
|---|---|---|---|---|---|---|---|
| sections | "checks" | "competitorUrls" | "sourceUrls" | "writingGuidelines" | "valueProposition" | "leadMagnet" | "learnings"[] | Which sections to read. Omit for all seven. |
Replace one or more sections of what the operator has told Kairon: checks, competitorUrls, sourceUrls, writingGuidelines, 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.
| Name | Type | What it is for |
|---|---|---|
| checks | object[] | Replaces every content check. An empty array removes them all. |
| competitorUrls | string[] | Replaces every competitor URL. Each must be a LinkedIn URL. |
| sourceUrls | string[] | Replaces every source URL. Any http(s) URL. |
| writingGuidelines | string | Replaces the caller's writing guidelines. An empty string clears them. |
| 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 |
|---|---|---|
| slugrequired | 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.
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 config_get for the operator's own voice, offer and quality checks.
| Name | Type | What it is for |
|---|---|---|
| namerequired | 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.
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.
It will not write your ICP or qualify accounts
That is on purpose. Those are interviews and judgment calls the agents inside Kairon do better, so they stayed in the app. An assistant can read an ICP and point a campaign at it.
What a refusal looks like
The tool result
{
"isError": true,
"content": [{ "type": "text", "text": "…" }]
}
That text, parsed
{
"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
Privacy and data
Everything runs over HTTPS. Your assistant signs in as you and acts as the LinkedIn seat you connected, inside the same daily caps and sending hours the app uses. It cannot raise its own limits. Every invite, message and search it makes appears in your Kairon activity, marked as coming from an agent, so you can see what it did and to whom.